23 Commits

Author SHA1 Message Date
nkanaev
08ad04401d Update changelog.md 2025-10-02 19:31:37 +01:00
nkanaev
a851d8ac9d minor ui tweaks 2025-10-02 19:31:37 +01:00
Your Name
5a3547e32e host build for openbsd 2025-10-02 10:26:44 +01:00
Your Name
b24152c19a fix mustHideFolder 2025-10-02 10:23:29 +01:00
nkanaev
9f93298cf9 restrict private IP access 2025-10-02 10:16:35 +01:00
Adam Simpson
ac9b635ed8 app: add support for theme-color
I use the "web app" version of yarr on my iPhone and the area around the
notch/island is un-themed.

Using [theme-color][1] we can control that color.

[1]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/theme-color
2025-09-25 14:28:15 +01:00
nkanaev
72a1930b9e rewrite feed navigation 2025-09-24 23:07:26 +01:00
nkanaev
e339354cc9 keyboard shortcut to close article 2025-09-23 22:08:04 +01:00
nkanaev
4b3a278679 Update changelog.md 2025-09-23 21:59:32 +01:00
nkanaev
aa06e65c59 redesign auto-refresh UI 2025-09-23 21:59:32 +01:00
nkanaev
dd57abefdd Update .gitignore 2025-09-23 21:59:32 +01:00
nkanaev
be8ba62bb1 make manifest.json public 2025-09-23 21:59:32 +01:00
nkanaev
b7895f6743 auth cookie directives 2025-09-23 21:59:32 +01:00
daigennki
ebe7b130b8 Add more categories to desktop shortcut
Ensures that it doesn't end up in "Lost & Found" on KDE Plasma's "Applications" menu.
2025-08-20 21:10:36 +01:00
nkanaev
7fe688e97c smooth scrolling on iOS 2025-06-04 22:11:50 +01:00
Bernhard Fröhlich
6b02a09f75 Update open_etc.go
Fix build on FreeBSD
2025-06-04 21:54:44 +01:00
Jason Rogena
f0d2ab6493 Add support for ARM64 Docker images
Commit updates the build-docker workflow to add suport for ARM64 images.

Testing
=======

Locally, I attempted to build an image from the provided Dockerfile in an
ARM64 host using:

```sh
podman build -t yarr:latest -f etc/dockerfile .
```

The `platforms` input is provided as documented in [1].

1 - https://github.com/docker/build-push-action?tab=readme-ov-file#inputs

Signed-off-by: Jason Rogena <null+git@rogena.me>
2025-04-28 11:50:19 +01:00
dependabot[bot]
42ee0372fe Bump golang.org/x/net from 0.37.0 to 0.38.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.37.0 to 0.38.0.
- [Commits](https://github.com/golang/net/compare/v0.37.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.38.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-17 15:55:22 +01:00
nkanaev
9762e09cb3 Update changelog.txt 2025-03-27 22:24:14 +00:00
Rohit Vighne
dd8b7ab27d Listen on AF_UNIX socket if -addr is a path 2025-03-27 22:22:20 +00:00
nkanaev
c348593ef4 Update readme.md 2025-03-27 09:38:18 +00:00
nkanaev
a51da7b8ec Update readme.md 2025-03-26 15:11:42 +00:00
nkanaev
33503f7896 update changelog 2025-03-26 14:53:04 +00:00
27 changed files with 880 additions and 690 deletions

View File

@@ -38,3 +38,4 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@
*.db-wal *.db-wal
*.syso *.syso
versioninfo.rc versioninfo.rc
.DS_Store

View File

@@ -90,6 +90,10 @@ func main() {
log.SetOutput(os.Stdout) log.SetOutput(os.Stdout)
} }
if open && strings.HasPrefix(addr, "unix:") {
log.Fatal("Cannot open ", addr, " in browser")
}
if db == "" { if db == "" {
configPath, err := os.UserConfigDir() configPath, err := os.UserConfigDir()
if err != nil { if err != nil {

View File

@@ -1,9 +1,19 @@
# upcoming # upcoming
- (new) serve on unix socket (thanks to @rvighne)
- (new) more auto-refresh options: 12h & 24h (thanks to @aswerkljh for suggestion)
- (fix) smooth scrolling on iOS (thanks to gatheraled)
- (etc) theme-color support (thanks to @asimpson)
- (etc) cookie security measures (thanks to Tom Fitzhenry)
- (etc) restrict access to internal IPs for page crawler (thanks to Omar Kurt)
# v2.5 (2025-03-26)
- (new) Fever API support (thanks to @icefed) - (new) Fever API support (thanks to @icefed)
- (new) editable feed link (thanks to @adaszko) - (new) editable feed link (thanks to @adaszko)
- (new) switch to feed by clicking the title in the article page (thanks to @tarasglek for suggestion) - (new) switch to feed by clicking the title in the article page (thanks to @tarasglek for suggestion)
- (new) support multiple media links - (new) support multiple media links
- (new) next/prev article navigation buttons (thanks to @tillcash)
- (fix) duplicate articles caused by the same feed addition (thanks to @adaszko) - (fix) duplicate articles caused by the same feed addition (thanks to @adaszko)
- (fix) relative article links (thanks to @adazsko for the report) - (fix) relative article links (thanks to @adazsko for the report)
- (fix) atom article links stored in id element (thanks to @adazsko for the report) - (fix) atom article links stored in id element (thanks to @adazsko for the report)
@@ -17,6 +27,7 @@
- (etc) load external images with no-referrer policy (thanks to @tillcash for the report) - (etc) load external images with no-referrer policy (thanks to @tillcash for the report)
- (etc) open external links with no-referrer policy (thanks to @donovanglover) - (etc) open external links with no-referrer policy (thanks to @donovanglover)
- (etc) show article content in the list if title is missing (thanks to @asimpson for suggestion) - (etc) show article content in the list if title is missing (thanks to @asimpson for suggestion)
- (etc) accessibility improvements (thanks to @tseykovets)
# v2.4 (2023-08-15) # v2.4 (2023-08-15)

View File

@@ -10,7 +10,7 @@ Name=yarr
Exec=$HOME/.local/bin/yarr -open Exec=$HOME/.local/bin/yarr -open
Icon=yarr Icon=yarr
Type=Application Type=Application
Categories=Internet; Categories=Internet;Network;News;Feed;
END END
if [[ ! -d "$HOME/.local/share/icons" ]]; then if [[ ! -d "$HOME/.local/share/icons" ]]; then

2
go.mod
View File

@@ -6,7 +6,7 @@ toolchain go1.23.5
require ( require (
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
golang.org/x/net v0.37.0 golang.org/x/net v0.38.0
golang.org/x/sys v0.31.0 golang.org/x/sys v0.31.0
) )

10
go.sum
View File

@@ -1,14 +1,8 @@
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=

View File

@@ -5,6 +5,7 @@ GO_TAGS = sqlite_foreign_keys sqlite_json
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)' GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)" GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
GO_FLAGS_DEBUG = -tags "$(GO_TAGS) debug"
GO_FLAGS_GUI = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS)" GO_FLAGS_GUI = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS)"
GO_FLAGS_GUI_WIN = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS) -H windowsgui" GO_FLAGS_GUI_WIN = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS) -H windowsgui"
@@ -75,7 +76,7 @@ windows_arm64_gui: src/platform/versioninfo.rc
GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
serve: serve:
go run $(GO_FLAGS) ./cmd/yarr -db local.db go run $(GO_FLAGS_DEBUG) ./cmd/yarr -db local.db
test: test:
go test $(GO_FLAGS) ./... go test $(GO_FLAGS) ./...

View File

@@ -9,21 +9,21 @@ The app is a single binary with an embedded database (SQLite).
## usage ## usage
The latest prebuilt binaries for Linux/MacOS/Windows AMD64 are available The latest prebuilt binaries for Linux/MacOS/Windows are available
[here](https://github.com/nkanaev/yarr/releases/latest). Installation instructions: [here](https://github.com/nkanaev/yarr/releases/latest).
The archives follow the naming convention `yarr_{OS}_{ARCH}[_gui].zip`, where:
* MacOS * `OS` is the target operating system
* `ARCH` is the CPU architecture (`arm64` for AArch64, `amd64` for X86-64)
* `-gui` indicates that the binary ships with the GUI (tray icon), and is a command line application if omitted
Download `yarr-*-macos64.zip`, unzip it, place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open". Usage instructions:
* Windows * MacOS: place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
Download `yarr-*-windows64.zip`, unzip it, open `yarr.exe`, click the anchor system tray icon, select "Open". * Windows: open `yarr.exe`, click the anchor system tray icon, select "Open".
* Linux * Linux: place `yarr` in `$HOME/.local/bin` and run [the script](etc/install-linux.sh).
Download `yarr-*-linux64.zip`, unzip it, place `yarr` in `$HOME/.local/bin`
and run [the script](etc/install-linux.sh).
[macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac [macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac

View File

@@ -8,6 +8,7 @@ import (
"io/ioutil" "io/ioutil"
"log" "log"
"os" "os"
"fmt"
) )
type assetsfs struct { type assetsfs struct {
@@ -19,8 +20,10 @@ var FS assetsfs
func (afs assetsfs) Open(name string) (fs.File, error) { func (afs assetsfs) Open(name string) (fs.File, error) {
if afs.embedded != nil { if afs.embedded != nil {
fmt.Println("serving from embedded")
return afs.embedded.Open(name) return afs.embedded.Open(name)
} }
fmt.Println("serving local")
return os.DirFS("src/assets").Open(name) return os.DirFS("src/assets").Open(name)
} }

View File

@@ -1,3 +1,5 @@
//go:build !debug
package assets package assets
import "embed" import "embed"

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-up"><polyline points="18 15 12 9 6 15"></polyline></svg>

After

Width:  |  Height:  |  Size: 268 B

View File

@@ -8,6 +8,7 @@
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml"> <link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png"> <link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
<link rel="manifest" href="./manifest.json" /> <link rel="manifest" href="./manifest.json" />
<meta name="theme-color" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<script> <script>
window.app = window.app || {} window.app = window.app || {}
@@ -23,21 +24,21 @@
<div class="p-2 toolbar d-flex align-items-center"> <div class="p-2 toolbar d-flex align-items-center">
<div class="icon mx-2">{% inline "anchor.svg" %}</div> <div class="icon mx-2">{% inline "anchor.svg" %}</div>
<div class="flex-grow-1"></div> <div class="flex-grow-1"></div>
<button class="toolbar-item" <button class="toolbar-item ml-1"
:class="{active: filterSelected == 'unread'}" :class="{active: filterSelected == 'unread'}"
:aria-pressed="filterSelected == 'unread'" :aria-pressed="filterSelected == 'unread'"
title="Unread" title="Unread"
@click="filterSelected = 'unread'"> @click="filterSelected = 'unread'">
<span class="icon">{% inline "circle-full.svg" %}</span> <span class="icon">{% inline "circle-full.svg" %}</span>
</button> </button>
<button class="toolbar-item" <button class="toolbar-item mx-1"
:class="{active: filterSelected == 'starred'}" :class="{active: filterSelected == 'starred'}"
:aria-pressed="filterSelected == 'starred'" :aria-pressed="filterSelected == 'starred'"
title="Starred" title="Starred"
@click="filterSelected = 'starred'"> @click="filterSelected = 'starred'">
<span class="icon">{% inline "star-full.svg" %}</span> <span class="icon">{% inline "star-full.svg" %}</span>
</button> </button>
<button class="toolbar-item" <button class="toolbar-item mr-1"
:class="{active: filterSelected == ''}" :class="{active: filterSelected == ''}"
:aria-pressed="filterSelected == ''" :aria-pressed="filterSelected == ''"
title="All" title="All"
@@ -78,12 +79,20 @@
<header class="dropdown-header" role="heading" aria-level="2">Auto Refresh</header> <header class="dropdown-header" role="heading" aria-level="2">Auto Refresh</header>
<div class="row text-center m-0"> <div class="row text-center m-0">
<button class="dropdown-item col-4 px-0" :aria-pressed="!refreshRate" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button> <button class="dropdown-item col-4 px-0"
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 10" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button> @click.stop="changeRefreshRate(-1)"
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 30" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button> :disabled="!refreshRate">
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 60" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button> <span class="icon">
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 120" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button> {% inline "chevron-down.svg" %}
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 240" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button> </span>
</button>
<div class="col-4 d-flex align-items-center justify-content-center">{{ refreshRateTitle }}</div>
<button class="dropdown-item col-4 px-0"
@click.stop="changeRefreshRate(1)" :disabled="refreshRate === refreshRateOptions.at(-1).value">
<span class="icon">
{% inline "chevron-up.svg" %}
</span>
</button>
</div> </div>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
@@ -122,7 +131,7 @@
</button> </button>
</dropdown> </dropdown>
</div> </div>
<div id="feed-list-scroll" class="p-2 overflow-auto border-top flex-grow-1"> <div id="feed-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1">
<label class="selectgroup"> <label class="selectgroup">
<input type="radio" name="feed" value="" v-model="feedSelected"> <input type="radio" name="feed" value="" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100"> <div class="selectgroup-label d-flex align-items-center w-100">
@@ -135,10 +144,8 @@
</label> </label>
<div v-for="folder in foldersWithFeeds"> <div v-for="folder in foldersWithFeeds">
<label class="selectgroup mt-1" <label class="selectgroup mt-1"
:class="{'d-none': filterSelected :class="{'d-none': mustHideFolder(folder)}"
&& !(current.folder.id == folder.id || current.feed.folder_id == folder.id) v-if="folder.id">
&& !filteredFolderStats[folder.id]
&& (!itemSelectedDetails || (feedsById[itemSelectedDetails.feed_id] || {}).folder_id != folder.id)}">
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id"> <input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id"> <div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
<span class="icon mr-2" <span class="icon mr-2"
@@ -152,10 +159,7 @@
</label> </label>
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}"> <div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
<label class="selectgroup" <label class="selectgroup"
:class="{'d-none': filterSelected :class="{'d-none': mustHideFeed(feed)}"
&& !(current.feed.id == feed.id)
&& !filteredFeedStats[feed.id]
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
v-for="feed in folder.feeds"> v-for="feed in folder.feeds">
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected"> <input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100"> <div class="selectgroup-label d-flex align-items-center w-100">
@@ -272,7 +276,7 @@
</button> </button>
</dropdown> </dropdown>
</div> </div>
<div id="item-list-scroll" class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist"> <div id="item-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
<label v-for="item in items" :key="item.id" <label v-for="item in items" :key="item.id"
class="selectgroup"> class="selectgroup">
<input type="radio" name="item" :value="item.id" v-model="itemSelected"> <input type="radio" name="item" :value="item.id" v-model="itemSelected">
@@ -347,7 +351,7 @@
</div> </div>
<div v-if="itemSelectedDetails" <div v-if="itemSelectedDetails"
ref="content" ref="content"
class="content px-4 pt-3 pb-5 border-top overflow-auto" class="content px-4 pt-3 pb-5 border-top overflow-auto scroll-touch"
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}" :class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
:style="{'font-size': theme.size + 'rem'}"> :style="{'font-size': theme.size + 'rem'}">
<div class="content-wrapper"> <div class="content-wrapper">
@@ -419,6 +423,7 @@
<tr><td colspan=2>&nbsp;</td></tr> <tr><td colspan=2>&nbsp;</td></tr>
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr> <tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr> <tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
<tr><td><kbd>q</kbd></td> <td>close article</td></tr>
<tr><td colspan=2>&nbsp;</td></tr> <tr><td colspan=2>&nbsp;</td></tr>
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr> <tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>

View File

@@ -211,6 +211,7 @@ var vm = new Vue({
api.feeds.list_errors().then(function(errors) { api.feeds.list_errors().then(function(errors) {
vm.feed_errors = errors vm.feed_errors = errors
}) })
this.updateMetaTheme(app.settings.theme_name)
}, },
data: function() { data: function() {
var s = app.settings var s = app.settings
@@ -249,9 +250,25 @@ var vm = new Vue({
'font': s.theme_font, 'font': s.theme_font,
'size': s.theme_size, 'size': s.theme_size,
}, },
'themeColors': {
'night': '#0e0e0e',
'sepia': '#f4f0e5',
'light': '#fff',
},
'refreshRate': s.refresh_rate, 'refreshRate': s.refresh_rate,
'authenticated': app.authenticated, 'authenticated': app.authenticated,
'feed_errors': {}, 'feed_errors': {},
'refreshRateOptions': [
{ title: "0", value: 0 },
{ title: "10m", value: 10 },
{ title: "30m", value: 30 },
{ title: "1h", value: 60 },
{ title: "2h", value: 120 },
{ title: "4h", value: 240 },
{ title: "12h", value: 720 },
{ title: "24h", value: 1440 },
],
} }
}, },
computed: { computed: {
@@ -309,12 +326,17 @@ var vm = new Vue({
contentVideos: function() { contentVideos: function() {
if (!this.itemSelectedDetails) return [] if (!this.itemSelectedDetails) return []
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'video') return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'video')
} },
refreshRateTitle: function () {
const entry = this.refreshRateOptions.find(o => o.value === this.refreshRate)
return entry ? entry.title : '0'
},
}, },
watch: { watch: {
'theme': { 'theme': {
deep: true, deep: true,
handler: function(theme) { handler: function(theme) {
this.updateMetaTheme(theme.name)
document.body.classList.value = 'theme-' + theme.name document.body.classList.value = 'theme-' + theme.name
api.settings.update({ api.settings.update({
theme_name: theme.name, theme_name: theme.name,
@@ -390,6 +412,9 @@ var vm = new Vue({
}, },
}, },
methods: { methods: {
updateMetaTheme: function(theme) {
document.querySelector("meta[name='theme-color']").content = this.themeColors[theme]
},
refreshStats: function(loopMode) { refreshStats: function(loopMode) {
return api.status().then(function(data) { return api.status().then(function(data) {
if (loopMode && !vm.itemSelected) vm.refreshItems() if (loopMode && !vm.itemSelected) vm.refreshItems()
@@ -753,9 +778,16 @@ var vm = new Vue({
// navigation helper, navigate relative to selected feed // navigation helper, navigate relative to selected feed
navigateToFeed: function(relativePosition) { navigateToFeed: function(relativePosition) {
let vm = this let vm = this
var navigationList = Array.from(document.querySelectorAll('#col-feed-list input[name=feed]')) const navigationList = this.foldersWithFeeds
.filter(function(r) { return r.offsetParent !== null && r.value !== 'folder:null' }) .filter(folder => !folder.id || !vm.mustHideFolder(folder))
.map(function(r) { return r.value }) .map((folder) => {
if (this.mustHideFolder(folder)) return []
const folds = folder.id ? [`folder:${folder.id}`] : []
const feeds = (folder.is_expanded || !folder.id) ? folder.feeds.filter(f => !vm.mustHideFeed(f)).map(f => `feed:${f.id}`) : []
return folds.concat(feeds)
})
.flat()
navigationList.unshift('')
var currentFeedPosition = navigationList.indexOf(vm.feedSelected) var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
@@ -778,6 +810,24 @@ var vm = new Vue({
if (target && scroll) scrollto(target, scroll) if (target && scroll) scrollto(target, scroll)
}) })
}, },
changeRefreshRate: function(offset) {
const curIdx = this.refreshRateOptions.findIndex(o => o.value === this.refreshRate)
if (curIdx <= 0 && offset < 0) return
if (curIdx >= (this.refreshRateOptions.length - 1) && offset > 0) return
this.refreshRate = this.refreshRateOptions[curIdx + offset].value
},
mustHideFolder: function (folder) {
return this.filterSelected
&& !(this.current.folder.id == folder.id || this.current.feed.folder_id == folder.id)
&& !this.filteredFolderStats[folder.id]
&& (!this.itemSelectedDetails || (this.feedsById[this.itemSelectedDetails.feed_id] || {}).folder_id != folder.id)
},
mustHideFeed: function (feed) {
return this.filterSelected
&& !(this.current.feed.id == feed.id)
&& !this.filteredFeedStats[feed.id]
&& (!this.itemSelectedDetails || this.itemSelectedDetails.feed_id != feed.id)
},
} }
}) })

View File

@@ -60,6 +60,9 @@ var shortcutFunctions = {
scrollBackward: function() { scrollBackward: function() {
helperFunctions.scrollContent(-1) helperFunctions.scrollContent(-1)
}, },
closeItem: function () {
vm.itemSelected = null
},
showAll() { showAll() {
vm.filterSelected = '' vm.filterSelected = ''
}, },
@@ -85,6 +88,7 @@ var keybindings = {
"h": shortcutFunctions.previousFeed, "h": shortcutFunctions.previousFeed,
"f": shortcutFunctions.scrollForward, "f": shortcutFunctions.scrollForward,
"b": shortcutFunctions.scrollBackward, "b": shortcutFunctions.scrollBackward,
"q": shortcutFunctions.closeItem,
"1": shortcutFunctions.showUnread, "1": shortcutFunctions.showUnread,
"2": shortcutFunctions.showStarred, "2": shortcutFunctions.showStarred,
"3": shortcutFunctions.showAll, "3": shortcutFunctions.showAll,
@@ -103,6 +107,7 @@ var codebindings = {
"KeyH": shortcutFunctions.previousFeed, "KeyH": shortcutFunctions.previousFeed,
"KeyF": shortcutFunctions.scrollForward, "KeyF": shortcutFunctions.scrollForward,
"KeyB": shortcutFunctions.scrollBackward, "KeyB": shortcutFunctions.scrollBackward,
"KeyQ": shortcutFunctions.closeItem,
"Digit1": shortcutFunctions.showUnread, "Digit1": shortcutFunctions.showUnread,
"Digit2": shortcutFunctions.showStarred, "Digit2": shortcutFunctions.showStarred,
"Digit3": shortcutFunctions.showAll, "Digit3": shortcutFunctions.showAll,

View File

@@ -100,6 +100,10 @@ select.form-control:not([multiple]):not([size]) {
padding-right: 0; padding-right: 0;
} }
.scroll-touch {
-webkit-overflow-scrolling: touch;
}
/* custom elements */ /* custom elements */
.font-serif { .font-serif {

View File

@@ -1,4 +1,4 @@
//go:build linux //go:build linux || freebsd || openbsd
package platform package platform

View File

@@ -7,7 +7,6 @@ import (
"encoding/hex" "encoding/hex"
"net/http" "net/http"
"strings" "strings"
"time"
) )
func IsAuthenticated(req *http.Request, username, password string) bool { func IsAuthenticated(req *http.Request, username, password string) bool {
@@ -26,8 +25,10 @@ func Authenticate(rw http.ResponseWriter, username, password, basepath string) {
http.SetCookie(rw, &http.Cookie{ http.SetCookie(rw, &http.Cookie{
Name: "auth", Name: "auth",
Value: username + ":" + secret(username, password), Value: username + ":" + secret(username, password),
Expires: time.Now().Add(time.Hour * 24 * 7), // 1 week, MaxAge: 604800, // 1 week
Path: basepath, Path: basepath,
Secure: true,
SameSite: http.SameSiteLaxMode,
}) })
} }

View File

@@ -34,7 +34,7 @@ func (s *Server) handler() http.Handler {
BasePath: s.BasePath, BasePath: s.BasePath,
Username: s.Username, Username: s.Username,
Password: s.Password, Password: s.Password,
Public: []string{"/static", "/fever"}, Public: []string{"/static", "/fever", "/manifest.json"},
DB: s.db, DB: s.db,
} }
r.Use(a.Handler) r.Use(a.Handler)
@@ -513,6 +513,10 @@ func (s *Server) handlePageCrawl(c *router.Context) {
}) })
return return
} }
if isInternalFromURL(url) {
log.Printf("attempt to access internal IP %s from %s", url, c.Req.RemoteAddr)
return
}
body, err := worker.GetBody(url) body, err := worker.GetBody(url)
if err != nil { if err != nil {

View File

@@ -2,7 +2,10 @@ package server
import ( import (
"log" "log"
"net"
"net/http" "net/http"
"os"
"strings"
"sync" "sync"
"github.com/nkanaev/yarr/src/storage" "github.com/nkanaev/yarr/src/storage"
@@ -53,14 +56,31 @@ func (s *Server) Start() {
s.worker.RefreshFeeds() s.worker.RefreshFeeds()
} }
httpserver := &http.Server{Addr: s.Addr, Handler: s.handler()} var ln net.Listener
var err error var err error
if s.CertFile != "" && s.KeyFile != "" {
err = httpserver.ListenAndServeTLS(s.CertFile, s.KeyFile) if path, isUnix := strings.CutPrefix(s.Addr, "unix:"); isUnix {
} else { err = os.Remove(path)
err = httpserver.ListenAndServe() if err != nil {
log.Print(err)
} }
ln, err = net.Listen("unix", path)
} else {
ln, err = net.Listen("tcp", s.Addr)
}
if err != nil {
log.Fatal(err)
}
httpserver := &http.Server{Handler: s.handler()}
if s.CertFile != "" && s.KeyFile != "" {
err = httpserver.ServeTLS(ln, s.CertFile, s.KeyFile)
ln.Close()
} else {
err = httpserver.Serve(ln)
}
if err != http.ErrServerClosed { if err != http.ErrServerClosed {
log.Fatal(err) log.Fatal(err)
} }

35
src/server/util.go Normal file
View File

@@ -0,0 +1,35 @@
package server
import (
"net"
"net/url"
"strings"
)
func isInternalFromURL(urlStr string) bool {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return false
}
host := parsedURL.Host
// Handle "host:port" format
if strings.Contains(host, ":") {
host, _, err = net.SplitHostPort(host)
if err != nil {
return false
}
}
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()
}

31
src/server/util_test.go Normal file
View File

@@ -0,0 +1,31 @@
package server
import "testing"
func TestIsInternalFromURL(t *testing.T) {
tests := []struct {
url string
expected bool
}{
{"http://192.168.1.1:8080", true},
{"http://10.0.0.5", true},
{"http://172.16.0.1", true},
{"http://172.31.255.255", true},
{"http://172.32.0.1", false}, // outside private range
{"http://127.0.0.1", true},
{"http://127.0.0.1:7000", true},
{"http://127.0.0.1:7000/secret", true},
{"http://169.254.0.5", true},
{"http://localhost", true}, // resolves to 127.0.0.1
{"http://8.8.8.8", false},
{"http://google.com", false}, // resolves to public IPs
{"invalid-url", false}, // invalid format
{"", false}, // empty string
}
for _, test := range tests {
result := isInternalFromURL(test.url)
if result != test.expected {
t.Errorf("isInternalFromURL(%q) = %v; want %v", test.url, result, test.expected)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -924,7 +924,7 @@ func inBodyIM(p *parser) bool {
p.addElement() p.addElement()
p.im = inFramesetIM p.im = inFramesetIM
return true return true
case a.Address, a.Article, a.Aside, a.Blockquote, a.Center, a.Details, a.Dialog, a.Dir, a.Div, a.Dl, a.Fieldset, a.Figcaption, a.Figure, a.Footer, a.Header, a.Hgroup, a.Main, a.Menu, a.Nav, a.Ol, a.P, a.Section, a.Summary, a.Ul: case a.Address, a.Article, a.Aside, a.Blockquote, a.Center, a.Details, a.Dialog, a.Dir, a.Div, a.Dl, a.Fieldset, a.Figcaption, a.Figure, a.Footer, a.Header, a.Hgroup, a.Main, a.Menu, a.Nav, a.Ol, a.P, a.Search, a.Section, a.Summary, a.Ul:
p.popUntil(buttonScope, a.P) p.popUntil(buttonScope, a.P)
p.addElement() p.addElement()
case a.H1, a.H2, a.H3, a.H4, a.H5, a.H6: case a.H1, a.H2, a.H3, a.H4, a.H5, a.H6:
@@ -1136,7 +1136,7 @@ func inBodyIM(p *parser) bool {
return false return false
} }
return true return true
case a.Address, a.Article, a.Aside, a.Blockquote, a.Button, a.Center, a.Details, a.Dialog, a.Dir, a.Div, a.Dl, a.Fieldset, a.Figcaption, a.Figure, a.Footer, a.Header, a.Hgroup, a.Listing, a.Main, a.Menu, a.Nav, a.Ol, a.Pre, a.Section, a.Summary, a.Ul: case a.Address, a.Article, a.Aside, a.Blockquote, a.Button, a.Center, a.Details, a.Dialog, a.Dir, a.Div, a.Dl, a.Fieldset, a.Figcaption, a.Figure, a.Footer, a.Header, a.Hgroup, a.Listing, a.Main, a.Menu, a.Nav, a.Ol, a.Pre, a.Search, a.Section, a.Summary, a.Ul:
p.popUntil(defaultScope, p.tok.DataAtom) p.popUntil(defaultScope, p.tok.DataAtom)
case a.Form: case a.Form:
if p.oe.contains(a.Template) { if p.oe.contains(a.Template) {

View File

@@ -839,8 +839,22 @@ func (z *Tokenizer) readStartTag() TokenType {
if raw { if raw {
z.rawTag = strings.ToLower(string(z.buf[z.data.start:z.data.end])) z.rawTag = strings.ToLower(string(z.buf[z.data.start:z.data.end]))
} }
// Look for a self-closing token like "<br/>". // Look for a self-closing token (e.g. <br/>).
if z.err == nil && z.buf[z.raw.end-2] == '/' { //
// Originally, we did this by just checking that the last character of the
// tag (ignoring the closing bracket) was a solidus (/) character, but this
// is not always accurate.
//
// We need to be careful that we don't misinterpret a non-self-closing tag
// as self-closing, as can happen if the tag contains unquoted attribute
// values (i.e. <p a=/>).
//
// To avoid this, we check that the last non-bracket character of the tag
// (z.raw.end-2) isn't the same character as the last non-quote character of
// the last attribute of the tag (z.pendingAttr[1].end-1), if the tag has
// attributes.
nAttrs := len(z.attr)
if z.err == nil && z.buf[z.raw.end-2] == '/' && (nAttrs == 0 || z.raw.end-2 != z.attr[nAttrs-1][1].end-1) {
return SelfClosingTagToken return SelfClosingTagToken
} }
return StartTagToken return StartTagToken

2
vendor/modules.txt vendored
View File

@@ -1,7 +1,7 @@
# github.com/mattn/go-sqlite3 v1.14.24 # github.com/mattn/go-sqlite3 v1.14.24
## explicit; go 1.19 ## explicit; go 1.19
github.com/mattn/go-sqlite3 github.com/mattn/go-sqlite3
# golang.org/x/net v0.37.0 # golang.org/x/net v0.38.0
## explicit; go 1.23.0 ## explicit; go 1.23.0
golang.org/x/net/html golang.org/x/net/html
golang.org/x/net/html/atom golang.org/x/net/html/atom