mirror of
https://github.com/nkanaev/yarr.git
synced 2025-11-07 09:59:38 +00:00
Compare commits
23 Commits
v2.5
...
08ad04401d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08ad04401d | ||
|
|
a851d8ac9d | ||
|
|
5a3547e32e | ||
|
|
b24152c19a | ||
|
|
9f93298cf9 | ||
|
|
ac9b635ed8 | ||
|
|
72a1930b9e | ||
|
|
e339354cc9 | ||
|
|
4b3a278679 | ||
|
|
aa06e65c59 | ||
|
|
dd57abefdd | ||
|
|
be8ba62bb1 | ||
|
|
b7895f6743 | ||
|
|
ebe7b130b8 | ||
|
|
7fe688e97c | ||
|
|
6b02a09f75 | ||
|
|
f0d2ab6493 | ||
|
|
42ee0372fe | ||
|
|
9762e09cb3 | ||
|
|
dd8b7ab27d | ||
|
|
c348593ef4 | ||
|
|
a51da7b8ec | ||
|
|
33503f7896 |
1
.github/workflows/build-docker.yml
vendored
1
.github/workflows/build-docker.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -6,3 +6,4 @@
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.syso
|
*.syso
|
||||||
versioninfo.rc
|
versioninfo.rc
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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
2
go.mod
@@ -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
10
go.sum
@@ -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=
|
||||||
|
|||||||
3
makefile
3
makefile
@@ -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) ./...
|
||||||
|
|||||||
20
readme.md
20
readme.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !debug
|
||||||
|
|
||||||
package assets
|
package assets
|
||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|||||||
1
src/assets/graphicarts/chevron-down.svg
Normal file
1
src/assets/graphicarts/chevron-down.svg
Normal 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 |
1
src/assets/graphicarts/chevron-up.svg
Normal file
1
src/assets/graphicarts/chevron-up.svg
Normal 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 |
@@ -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> </td></tr>
|
<tr><td colspan=2> </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> </td></tr>
|
<tr><td colspan=2> </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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build linux
|
//go:build linux || freebsd || openbsd
|
||||||
|
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -24,10 +23,12 @@ func IsAuthenticated(req *http.Request, username, password string) bool {
|
|||||||
|
|
||||||
func Authenticate(rw http.ResponseWriter, username, password, basepath string) {
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
err = os.Remove(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
}
|
||||||
|
ln, err = net.Listen("unix", path)
|
||||||
} else {
|
} else {
|
||||||
err = httpserver.ListenAndServe()
|
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
35
src/server/util.go
Normal 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
31
src/server/util_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1256
vendor/golang.org/x/net/html/atom/table.go
generated
vendored
1256
vendor/golang.org/x/net/html/atom/table.go
generated
vendored
File diff suppressed because it is too large
Load Diff
4
vendor/golang.org/x/net/html/parse.go
generated
vendored
4
vendor/golang.org/x/net/html/parse.go
generated
vendored
@@ -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) {
|
||||||
|
|||||||
18
vendor/golang.org/x/net/html/token.go
generated
vendored
18
vendor/golang.org/x/net/html/token.go
generated
vendored
@@ -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
2
vendor/modules.txt
vendored
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user