Compare commits

...

43 Commits

Author SHA1 Message Date
Frédéric Guillot 33063a7775 docs(changelog): update release notes for version 2.2.5 2025-01-20 11:12:46 -08:00
Frédéric Guillot 49c62db2e1 fix: update Wallabag URL label to avoid confusion 2025-01-18 17:22:49 -08:00
Frédéric Guillot 400e8974f9 fix: improve pagination when having identical publication date 2025-01-18 16:59:48 -08:00
Frédéric Guillot 9c82e55b98 fix: do not strip tags in Atom entry title 2025-01-18 15:33:44 -08:00
Frédéric Guillot c9c422b135 feat: bump linter and minifier from ECMAScript 2017 to 2020 (ES11) 2025-01-18 11:32:50 -08:00
Julien Voisin 91f9a7650e refactor(js): add jshint check for strict comparison 2025-01-16 17:50:09 -08:00
jvoisin 605eeb4525 Fix a mistake introduced in f67d2e230b
Spotted by @michaelkuhn
2025-01-16 17:37:54 -08:00
Julien Voisin eb6991ae49 tests(js): improve .jshintrc 2025-01-15 18:43:03 -08:00
Cthulhux eac5d59f5b feat(locale): update German translation
New ntfy string translated
2025-01-15 17:15:55 -08:00
dependabot[bot] 3861f1aa67 build(deps): bump github.com/tdewolff/minify/v2 from 2.21.2 to 2.21.3
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.21.2 to 2.21.3.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.21.2...v2.21.3)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-14 20:50:56 -08:00
Julien Voisin 6af0cd5b5c refactor(js): simplify a bit keyboard_handler.js
- Mark two methods as static
- Use a `switch-case` construct instead of an Object and a loop.
2025-01-14 20:50:21 -08:00
Julien Voisin fccca0ce1e refactor(js): minor refactoring of touch_handler.js
- Mark a method as `static`
- use `Math.sqrt` instead of `Math.pow(…, 0.5)`
- Use `Math.sign` instead of a condition on the sign
- Inline some used-once variables
- Reduce the scope of some variables
2025-01-14 20:47:30 -08:00
Julien Voisin 8c3a9184ac refactor(js): remove an outdated check for {passive: true}
According to https://caniuse.com/?search=passive,
all browsers released after 2016 do support passive event listeners,
so no need to check for its presence.
2025-01-14 20:18:11 -08:00
Matthaiks 3b317b0b80 feat(locale): update Polish translation 2025-01-13 18:38:22 -08:00
Brieuc Dubois a702bf0342 feat(ntfy): Add option to use internal links 2025-01-13 10:36:49 -08:00
Frédéric Guillot e9520f5d1c fix(finder): do not add redirections to the list of subscriptions to avoid confusion 2025-01-12 17:09:32 -08:00
Frédéric Guillot f5fde36d45 fix(ui): reading preferences are reset if the form values are incorrect 2025-01-12 16:16:29 -08:00
Jake Walker 6cbe8c3a9d feat: add fix_ghost_cards rewrite rule 2025-01-12 14:43:27 -08:00
Julien Voisin 1e54a073d3 refactor(js): minor improvements in app.js
- Use `….classList.toggle` instead of `….classList.add`/`….classList.remove` in a condition
- Replace a `function()` with a `() =>`
- Use `Math.min` instead of a handwritten condition
2025-01-12 12:54:08 -08:00
dependabot[bot] bf66ef5a0f build(deps): bump github.com/coreos/go-oidc/v3 from 3.11.0 to 3.12.0
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.11.0 to 3.12.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.11.0...v3.12.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-12 12:22:16 -08:00
CaptainArk 9b25ea4ed6 feat(integration): add Discord integration 2025-01-12 12:18:57 -08:00
Julien Voisin f116f7dd6a test(sanitizer): add a fuzzer 2025-01-11 17:19:31 -08:00
dependabot[bot] e540547104 build(deps): bump golang.org/x/oauth2 from 0.24.0 to 0.25.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.24.0 to 0.25.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.24.0...v0.25.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-11 17:12:57 -08:00
dependabot[bot] 5b1abbf340 build(deps): bump golang.org/x/net from 0.33.0 to 0.34.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.33.0 to 0.34.0.
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.34.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-11 17:05:27 -08:00
dependabot[bot] 0d72cef82e build(deps): bump golang.org/x/crypto from 0.31.0 to 0.32.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.31.0 to 0.32.0.
- [Commits](https://github.com/golang/crypto/compare/v0.31.0...v0.32.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-11 16:51:21 -08:00
Julien Voisin 79ec6ef81f feat(database): add optional build support for SQLite
Miniflux can be build with `go build -tags=sqlite` to test this. Note that
while it builds, it will fail at runtime, as some of the SQL used in miniflux is
postgresql-specific.
2024-12-29 15:09:26 -08:00
jvoisin 8d4954e29b Return an error should it happen in migrations 2024-12-29 11:51:47 -08:00
dependabot[bot] c81e17c20d build(deps): bump github.com/PuerkitoBio/goquery from 1.10.0 to 1.10.1
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.10.0 to 1.10.1.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.10.0...v1.10.1)

---
updated-dependencies:
- dependency-name: github.com/PuerkitoBio/goquery
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-27 15:08:35 -08:00
Frédéric Guillot 5549f75dd7 fix(sanitizer): allow <hr> tags 2024-12-27 13:56:06 -08:00
Frédéric Guillot fc3c4873e5 doc: add note regarding validateUsername 2024-12-26 15:05:15 -08:00
Frédéric Guillot b87c547a07 ci: run Docker tests only when the Dockerfiles are modified 2024-12-26 14:58:32 -08:00
Frédéric Guillot 3466e9e2d6 ci: avoid building Linux packages for each pull-request 2024-12-26 14:57:52 -08:00
Julien Voisin 8df4b780a8 refactor(readingtime): replace whatlanggo with an ad-hoc implementation
The package `github.com/abadojack/whatlanggo` is unmaintained since 5 years, is
overkill for simply detecting CJK, and is quite slow.
2024-12-26 14:21:07 -08:00
Julien Voisin e22520fc55 feat: validate usernames upon creation
The validation doesn't apply to already created usernames.

This should close #925
2024-12-26 14:14:07 -08:00
Julien Voisin 518bc4d6ff refactor(database): add special handling for PostgreSQL-specific migrations 2024-12-26 14:09:37 -08:00
Julien Voisin 89620a7dd2 refactor(oauth2): no need to use io.WriteString when sha256 provides a way to obtain a sum in a single call 2024-12-26 10:39:55 -08:00
Julien Voisin bbfe39722a ci: tighten the CodeQL rules
- don't run CodeQL on test files
- don't run CodeQL if no `.go` nor `.js` file have been modified.
2024-12-26 10:33:41 -08:00
Julien Voisin f3989cdb2f ci: checkout before installing Go
Obtaining the code before deploying go allows better caching, as the go.sum
file becomes available.

See https://github.com/actions/setup-go/issues/281
2024-12-23 21:24:22 -08:00
Julien Voisin 195b75d185 refactor(rewriter): use custom title case converter implementation instead of golang.org/x/text/cases.Title()
The implementation is equivalent to
`cases.Title(language.English).String(strings.ToLower(…))`,
and this is the only place in miniflux where
"golang.org/x/text/cases" and "golang.org/x/text/language"
are (directly) used.

This reduces the binary size from 27015590 to
26686112 on my machine.

Kudos to https://gsa.zxilly.dev for making it straightforward to catch things
like this.
2024-12-23 21:16:02 -08:00
Julien Voisin f52411f734 ci: only run -race -cover on Ubuntu
The coverage information isn't used anywhere in the CI, so no need to have it
for every OS. As for `-race`, there is no point in using it everywhere, one
time should be enough, especially since it's taking a lot of time on Windows.
2024-12-23 11:45:45 -08:00
Julien Voisin b93543f416 feat: replace %{?systemd_requires} with %{?systemd_ordering}
As said [in the documentation](https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/#_dependencies_on_the_systemd_package):

> If the package wants to use systemd tools if they are available, but does not
want to declare a dependency, then the `%{?systemd_ordering}` macro MAY be used
as a weaker form of %{?systemd_requires} that only declares an ordering during
an RPM transaction.

See https://github.com/systemd/systemd/commit/2424b6bd716f0c1c3bf3406b1fd1a16ba1b6a556
and https://pagure.io/packaging-committee/issue/644 for more information.

And also use `--setopt=install_weak_deps=False` to avoid installing a lot of
useless dependencies.
2024-12-23 11:34:29 -08:00
Julien Voisin 057f760196 ci: don't run go vet ./... as it's run as part of golangci-lint
See https://golangci-lint.run/usage/linters/#govet
2024-12-22 22:04:00 -08:00
Julien Voisin 28fe053329 ci: don't specify languages for CodeQL
As stated in the [documentation](https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning#changing-the-languages-that-are-analyzed):

> CodeQL code scanning automatically detects code written in the supported languages.

This will also reduce the number of CodeQL jobs from two to one.

See #3029
2024-12-22 22:00:06 -08:00
78 changed files with 1161 additions and 379 deletions
+2 -2
View File
@@ -9,13 +9,13 @@ jobs:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Golang
uses: actions/setup-go@v5
with:
go-version: "1.23.x"
check-latest: true
- name: Checkout
uses: actions/checkout@v4
- name: Compile binaries
env:
CGO_ENABLED: 0
+8 -2
View File
@@ -5,9 +5,17 @@ permissions: read-all
on:
push:
branches: [ main ]
paths:
- '**.js'
- '**.go'
- '!**_test.go'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
paths:
- '**.js'
- '**.go'
- '!**_test.go'
schedule:
- cron: '45 22 * * 3'
@@ -22,8 +30,6 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
steps:
- name: Checkout repository
+4 -4
View File
@@ -5,11 +5,11 @@ on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
schedule:
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
jobs:
test-packages:
if: github.event.pull_request
if: github.event_name == 'schedule'
name: Test Packages
runs-on: ubuntu-latest
steps:
@@ -30,7 +30,7 @@ jobs:
- name: List generated files
run: ls -l *.deb
build-packages-manually:
if: github.event_name != 'pull_request' && github.event_name != 'push'
if: github.event_name == 'workflow_dispatch'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
+2
View File
@@ -7,6 +7,8 @@ on:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
paths:
- 'packaging/docker/**'
jobs:
docker-images:
name: Docker Images
-1
View File
@@ -29,7 +29,6 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: "1.23.x"
- run: "go vet ./..."
- uses: golangci/golangci-lint-action@v6
with:
args: >
+4 -4
View File
@@ -5,11 +5,11 @@ on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
schedule:
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
jobs:
test-package:
if: github.event.pull_request
if: github.event_name == 'schedule'
name: Test Packages
runs-on: ubuntu-latest
steps:
@@ -21,7 +21,7 @@ jobs:
- name: List generated files
run: ls -l *.rpm
build-package-manually:
if: github.event_name != 'pull_request' && github.event_name != 'push'
if: github.event_name == 'workflow_dispatch'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
+9 -5
View File
@@ -17,14 +17,18 @@ jobs:
os: [ubuntu-latest, windows-latest, macOS-latest]
go-version: ["1.23.x"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- name: Checkout
uses: actions/checkout@v4
- name: Run unit tests
- name: Run unit tests with coverage and race conditions checking
if: matrix.os == 'ubuntu-latest'
run: make test
- name: Run unit tests without coverage and race conditions checking
if: matrix.os != 'ubuntu-latest'
run: go test ./...
integration-tests:
name: Integration Tests
@@ -40,12 +44,12 @@ jobs:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23.x"
- name: Checkout
uses: actions/checkout@v4
- name: Install Postgres client
run: sudo apt update && sudo apt install -y postgresql-client
- name: Run integration tests
+42
View File
@@ -1,3 +1,45 @@
Version 2.2.5 (January 20, 2025)
--------------------------------
* tests(js): improve `.jshintrc` (strict comparison, etc...)
* test(sanitizer): add a fuzzer
* refactor(rewriter): use custom title case converter implementation instead of `golang.org/x/text/cases.Title()`
* refactor(readingtime): replace `whatlanggo` package with an ad-hoc implementation
* refactor(oauth2): no need to use `io.WriteString` when sha256 provides a way to obtain a sum in a single call
* refactor(js): simplify a bit `keyboard_handler.js`
* refactor(js): remove an outdated check for `{passive: true}`
* refactor(js): minor refactoring of `touch_handler.js`
* refactor(js): minor improvements in `app.js`
* refactor(database): add special handling for PostgreSQL-specific migrations
* fix(ui): reading preferences are reset if the form values are incorrect
* fix(sanitizer): allow `<hr>` tags
* fix(finder): do not add redirections to the list of subscriptions to avoid confusion
* fix: update Wallabag URL label to avoid confusion
* fix: improve pagination when having identical publication date
* fix: do not strip tags in Atom entry title
* feat(ntfy): Add option to use internal links
* feat(locale): update Polish translation
* feat(locale): update German translation
* feat(integration): add Discord integration
* feat(database): add optional build support for SQLite
* feat: validate usernames upon creation
* feat: replace `%{?systemd_requires}` with `%{?systemd_ordering}`
* feat: bump linter and minifier from ECMAScript 2017 to 2020 (ES11)
* feat: add `fix_ghost_cards` rewrite rule
* ci: tighten the CodeQL rules
* ci: run Docker tests only when the Dockerfiles are modified
* ci: run `-race -cover` only on Ubuntu jobs
* ci: don't specify languages for CodeQL
* ci: don't run `go vet ./...` as it's run as part of `golangci-lint`
* ci: checkout before installing Go to improve cache efficiency
* ci: avoid building Linux packages for each pull-request
* build(deps): bump `golang.org/x/oauth2` from `0.24.0` to `0.25.0`
* build(deps): bump `golang.org/x/net` from `0.33.0` to `0.34.0`
* build(deps): bump `golang.org/x/crypto` from `0.31.0` to `0.32.0`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.21.2` to `2.21.3`
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.10.0` to `1.10.1`
* build(deps): bump `github.com/coreos/go-oidc/v3` from `3.11.0` to `3.12.0`
Version 2.2.4 (December 20, 2024)
---------------------------------
+11 -11
View File
@@ -3,21 +3,20 @@ module miniflux.app/v2
// +heroku goVersion go1.23
require (
github.com/PuerkitoBio/goquery v1.10.0
github.com/abadojack/whatlanggo v1.0.1
github.com/PuerkitoBio/goquery v1.10.1
github.com/andybalholm/brotli v1.1.1
github.com/coreos/go-oidc/v3 v3.11.0
github.com/coreos/go-oidc/v3 v3.12.0
github.com/go-webauthn/webauthn v0.11.2
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.24
github.com/prometheus/client_golang v1.20.5
github.com/tdewolff/minify/v2 v2.21.2
golang.org/x/crypto v0.31.0
github.com/tdewolff/minify/v2 v2.21.3
golang.org/x/crypto v0.32.0
golang.org/x/image v0.23.0
golang.org/x/net v0.33.0
golang.org/x/oauth2 v0.24.0
golang.org/x/term v0.27.0
golang.org/x/text v0.21.0
golang.org/x/net v0.34.0
golang.org/x/oauth2 v0.25.0
golang.org/x/term v0.28.0
)
require (
@@ -27,7 +26,7 @@ require (
)
require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
@@ -41,7 +40,8 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect
github.com/tdewolff/parse/v2 v2.7.19 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
+48 -19
View File
@@ -1,17 +1,15 @@
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
@@ -38,6 +36,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -54,8 +54,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.21.2 h1:VfTvmGVtBYhMTlUAeHtXM7XOsW0JT/6uMwUPPqgUs9k=
github.com/tdewolff/minify/v2 v2.21.2/go.mod h1:Olje3eHdBnrMjINKffDsil/3NV98Iv7MhWf7556WQVg=
github.com/tdewolff/minify/v2 v2.21.3 h1:KmhKNGrN/dGcvb2WDdB5yA49bo37s+hcD8RiF+lioV8=
github.com/tdewolff/minify/v2 v2.21.3/go.mod h1:iGxHaGiONAnsYuo8CRyf8iPUcqRJVB/RhtEcTpqS7xw=
github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg=
github.com/tdewolff/parse/v2 v2.7.19/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
@@ -68,50 +68,79 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+3 -19
View File
@@ -7,34 +7,18 @@ import (
"database/sql"
"fmt"
"log/slog"
"time"
// Postgresql driver import
_ "github.com/lib/pq"
)
// NewConnectionPool configures the database connection pool.
func NewConnectionPool(dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxConnections)
db.SetMaxIdleConns(minConnections)
db.SetConnMaxLifetime(connectionLifetime)
return db, nil
}
// Migrate executes database migrations.
func Migrate(db *sql.DB) error {
var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)
driver := getDriverStr()
slog.Info("Running database migrations",
slog.Int("current_version", currentVersion),
slog.Int("latest_version", schemaVersion),
slog.String("driver", driver),
)
for version := currentVersion; version < schemaVersion; version++ {
@@ -45,7 +29,7 @@ func Migrate(db *sql.DB) error {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if err := migrations[version](tx); err != nil {
if err := migrations[version](tx, driver); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
+168 -146
View File
@@ -10,8 +10,8 @@ import (
var schemaVersion = len(migrations)
// Order is important. Add new migrations at the end of the list.
var migrations = []func(tx *sql.Tx) error{
func(tx *sql.Tx) (err error) {
var migrations = []func(tx *sql.Tx, driver string) error{
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TABLE schema_version (
version text not null
@@ -120,16 +120,19 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
func(tx *sql.Tx, driver string) (err error) {
if driver == "postgresql" {
sql := `
CREATE EXTENSION IF NOT EXISTS hstore;
ALTER TABLE users ADD COLUMN extra hstore;
CREATE INDEX users_extra_idx ON users using gin(extra);
`
_, err = tx.Exec(sql)
return err
`
_, err = tx.Exec(sql)
return err
}
return nil
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TABLE tokens (
id text not null,
@@ -141,7 +144,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TYPE entry_sorting_direction AS enum('asc', 'desc');
ALTER TABLE users ADD COLUMN entry_direction entry_sorting_direction default 'asc';
@@ -149,7 +152,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TABLE integrations (
user_id int not null,
@@ -170,27 +173,27 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN scraper_rules text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN rewrite_rules text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN crawler boolean default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE sessions rename to user_sessions`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
DROP TABLE tokens;
@@ -204,7 +207,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN wallabag_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN wallabag_url text default '';
@@ -216,12 +219,12 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE entries ADD COLUMN starred bool default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE INDEX entries_user_status_idx ON entries(user_id, status);
CREATE INDEX feeds_user_category_idx ON feeds(user_id, category_id);
@@ -229,7 +232,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN nunux_keeper_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN nunux_keeper_url text default '';
@@ -238,17 +241,17 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE enclosures ALTER COLUMN size SET DATA TYPE bigint`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE entries ADD COLUMN comments_url text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN pocket_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN pocket_access_token text default '';
@@ -257,14 +260,14 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE user_sessions ALTER COLUMN ip SET DATA TYPE inet using ip::inet;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN username text default '';
ALTER TABLE feeds ADD COLUMN password text default '';
@@ -272,7 +275,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN document_vectors tsvector;
UPDATE entries SET document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
@@ -281,12 +284,12 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN user_agent text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
UPDATE
entries
@@ -296,17 +299,17 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN keyboard_shortcuts boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disabled boolean default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE users ALTER COLUMN theme SET DEFAULT 'light_serif';
UPDATE users SET theme='light_serif' WHERE theme='default';
@@ -316,7 +319,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN changed_at timestamp with time zone;
UPDATE entries SET changed_at = published_at;
@@ -325,7 +328,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TABLE api_keys (
id serial not null,
@@ -341,7 +344,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN share_code text not null default '';
CREATE UNIQUE INDEX entries_share_code_idx ON entries USING btree(share_code) WHERE share_code <> '';
@@ -349,12 +352,12 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN next_check_at timestamp with time zone default now();
CREATE INDEX entries_user_feed_idx ON entries (user_id, feed_id);
@@ -362,52 +365,52 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN ignore_http_cache bool default false`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN entries_per_page int default 100`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN show_reading_time boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `CREATE INDEX entries_id_user_status_idx ON entries USING btree (id, user_id, status)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN fetch_via_proxy bool default false`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `CREATE INDEX entries_feed_id_status_hash_idx ON entries USING btree (feed_id, status, hash)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `CREATE INDEX entries_user_id_status_starred_idx ON entries (user_id, status, starred)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN entry_swipe boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE integrations DROP COLUMN fever_password`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN blocklist_rules text not null default '',
@@ -416,12 +419,12 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE entries ADD COLUMN reading_time int not null default 0`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN created_at timestamp with time zone not null default now();
UPDATE entries SET created_at = published_at;
@@ -429,7 +432,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, driver string) (err error) {
_, err = tx.Exec(`
ALTER TABLE users
ADD column stylesheet text not null default '',
@@ -440,63 +443,69 @@ var migrations = []func(tx *sql.Tx) error{
return err
}
_, err = tx.Exec(`
DECLARE my_cursor CURSOR FOR
SELECT
id,
COALESCE(extra->'custom_css', '') as custom_css,
COALESCE(extra->'google_id', '') as google_id,
COALESCE(extra->'oidc_id', '') as oidc_id
FROM users
FOR UPDATE
`)
if err != nil {
return err
}
defer tx.Exec("CLOSE my_cursor")
for {
var (
userID int64
customStylesheet string
googleID string
oidcID string
)
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
if err == sql.ErrNoRows {
break
}
return err
}
_, err := tx.Exec(
`UPDATE
users
SET
stylesheet=$2,
google_id=$3,
openid_connect_id=$4
WHERE
id=$1
`,
userID, customStylesheet, googleID, oidcID)
if driver == "postgresql" {
_, err = tx.Exec(`
DECLARE my_cursor CURSOR FOR
SELECT
id,
COALESCE(extra->'custom_css', '') as custom_css,
COALESCE(extra->'google_id', '') as google_id,
COALESCE(extra->'oidc_id', '') as oidc_id
FROM users
FOR UPDATE
`)
if err != nil {
return err
}
defer tx.Exec("CLOSE my_cursor")
for {
var (
userID int64
customStylesheet string
googleID string
oidcID string
)
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
if err == sql.ErrNoRows {
break
}
return err
}
_, err := tx.Exec(
`UPDATE
users
SET
stylesheet=$2,
google_id=$3,
openid_connect_id=$4
WHERE
id=$1
`,
userID, customStylesheet, googleID, oidcID)
if err != nil {
return err
}
}
}
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, driver string) (err error) {
if driver == "postgresql" {
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN extra;`); err != nil {
return err
}
}
_, err = tx.Exec(`
ALTER TABLE users DROP COLUMN extra;
CREATE UNIQUE INDEX users_google_id_idx ON users(google_id) WHERE google_id <> '';
CREATE UNIQUE INDEX users_openid_connect_id_idx ON users(openid_connect_id) WHERE openid_connect_id <> '';
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
CREATE INDEX entries_feed_url_idx ON entries(feed_id, url);
CREATE INDEX entries_user_status_feed_idx ON entries(user_id, status, feed_id);
@@ -504,7 +513,7 @@ var migrations = []func(tx *sql.Tx) error{
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
CREATE TABLE acme_cache (
key varchar(400) not null primary key,
@@ -514,13 +523,13 @@ var migrations = []func(tx *sql.Tx) error{
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN allow_self_signed_certificates boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TYPE webapp_display_mode AS enum('fullscreen', 'standalone', 'minimal-ui', 'browser');
ALTER TABLE users ADD COLUMN display_mode webapp_display_mode default 'standalone';
@@ -528,24 +537,24 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN cookie text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE categories ADD COLUMN hide_globally boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN hide_globally boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN telegram_bot_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN telegram_bot_token text default '';
@@ -554,7 +563,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TYPE entry_sorting_order AS enum('published_at', 'created_at');
ALTER TABLE users ADD COLUMN entry_order entry_sorting_order default 'published_at';
@@ -562,7 +571,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN googlereader_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN googlereader_username text default '';
@@ -571,7 +580,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN espial_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN espial_url text default '';
@@ -581,7 +590,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN linkding_url text default '';
@@ -590,38 +599,38 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN url_rewrite_rules text not null default ''
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN default_reading_speed int default 265;
ALTER TABLE users ADD COLUMN cjk_reading_speed int default 500;
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN default_home_page text default 'unread';
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE integrations ADD COLUMN wallabag_only_url bool default 'f';
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN categories_sorting_order text not null default 'unread_count';
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN matrix_bot_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN matrix_bot_user text default '';
@@ -632,18 +641,18 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN double_tap boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE entries ADD COLUMN tags text[] default '{}';
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE users RENAME double_tap TO gesture_nav;
ALTER TABLE users ALTER COLUMN gesture_nav SET DATA TYPE text using case when gesture_nav = true then 'tap' when gesture_nav = false then 'none' end;
@@ -652,14 +661,14 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN no_media_player boolean default 'f';
ALTER TABLE enclosures ADD COLUMN media_progression int default 0;
@@ -667,14 +676,14 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_mark_as_unread bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
// Delete duplicated rows
sql := `
DELETE FROM enclosures a USING enclosures b
@@ -702,12 +711,12 @@ var migrations = []func(tx *sql.Tx) error{
return nil
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN mark_read_on_view boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN notion_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN notion_token text default '';
@@ -716,7 +725,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN readwise_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN readwise_api_key text default '';
@@ -724,7 +733,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN apprise_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN apprise_url text default '';
@@ -733,7 +742,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN shiori_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN shiori_url text default '';
@@ -743,7 +752,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN shaarli_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN shaarli_url text default '';
@@ -752,13 +761,13 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN apprise_service_urls text default '';
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN webhook_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN webhook_url text default '';
@@ -767,7 +776,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN telegram_bot_topic_id int;
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_web_page_preview bool default 'f';
@@ -776,14 +785,14 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_buttons bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
-- Speed up has_enclosure
CREATE INDEX enclosures_entry_id_idx ON enclosures(entry_id);
@@ -799,7 +808,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN rssbridge_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN rssbridge_url text default '';
@@ -807,7 +816,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
CREATE TABLE webauthn_credentials (
handle bytea primary key,
@@ -825,7 +834,7 @@ var migrations = []func(tx *sql.Tx) error{
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN omnivore_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN omnivore_api_key text default '';
@@ -834,7 +843,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkace_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN linkace_url text default '';
@@ -846,7 +855,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkwarden_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN linkwarden_url text default '';
@@ -855,7 +864,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN readeck_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN readeck_only_url bool default 'f';
@@ -866,29 +875,29 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disable_http2 bool default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN media_playback_rate numeric default 1;`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
// the WHERE part speed-up the request a lot
sql := `UPDATE entries SET tags = array_remove(tags, '') WHERE '' = ANY(tags);`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
// Entry URLs can exceeds btree maximum size
// Checking entry existence is now using entries_feed_id_status_hash_idx index
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN raindrop_token text default '';
@@ -898,12 +907,12 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE users
ADD COLUMN block_filter_entry_rules text not null default '',
@@ -912,7 +921,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN betula_url text default '';
ALTER TABLE integrations ADD COLUMN betula_token text default '';
@@ -921,7 +930,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN ntfy_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN ntfy_url text default '';
@@ -937,22 +946,22 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN mark_read_on_media_player_completion bool default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN cubox_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN cubox_api_link text default '';
@@ -960,4 +969,17 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN discord_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN discord_webhook_link text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE integrations ADD COLUMN ntfy_internal_links bool default 'f';`
_, err = tx.Exec(sql)
return err
},
}
+31
View File
@@ -0,0 +1,31 @@
//go:build !sqlite
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"time"
_ "github.com/lib/pq"
)
// NewConnectionPool configures the database connection pool.
func NewConnectionPool(dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxConnections)
db.SetMaxIdleConns(minConnections)
db.SetConnMaxLifetime(connectionLifetime)
return db, nil
}
func getDriverStr() string {
return "postgresql"
}
+26
View File
@@ -0,0 +1,26 @@
//go:build sqlite
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"time"
_ "github.com/mattn/go-sqlite3"
)
// NewConnectionPool configures the database connection pool.
func NewConnectionPool(dsn string, _, _ int, _ time.Duration) (*sql.DB, error) {
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, err
}
return db, nil
}
func getDriverStr() string {
return "sqlite3"
}
+109
View File
@@ -0,0 +1,109 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Discord Webhooks documentation: https://discord.com/developers/docs/resources/webhook
package discord // import "miniflux.app/v2/internal/integration/discord"
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
const discordMsgColor = 5793266
type Client struct {
webhookURL string
}
func NewClient(webhookURL string) *Client {
return &Client{webhookURL: webhookURL}
}
func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
for _, entry := range entries {
requestBody, err := json.Marshal(&discordMessage{
Embeds: []discordEmbed{
{
Title: "RSS feed update from Miniflux",
Color: discordMsgColor,
Fields: []discordFields{
{
Name: "Updated feed",
Value: feed.Title,
},
{
Name: "Article link",
Value: "[" + entry.Title + "]" + "(" + entry.URL + ")",
},
{
Name: "Author",
Value: entry.Author,
Inline: true,
},
{
Name: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Inline: true,
},
},
},
},
})
if err != nil {
return fmt.Errorf("discord: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("discord: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
slog.Debug("Sending Discord notification",
slog.String("webhookURL", c.webhookURL),
slog.String("title", feed.Title),
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
}
}
return nil
}
type discordFields struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline,omitempty"`
}
type discordEmbed struct {
Title string `json:"title"`
Color int `json:"color"`
Fields []discordFields `json:"fields"`
}
type discordMessage struct {
Embeds []discordEmbed `json:"embeds"`
}
+18
View File
@@ -10,6 +10,7 @@ import (
"miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/betula"
"miniflux.app/v2/internal/integration/cubox"
"miniflux.app/v2/internal/integration/discord"
"miniflux.app/v2/internal/integration/espial"
"miniflux.app/v2/internal/integration/instapaper"
"miniflux.app/v2/internal/integration/linkace"
@@ -505,6 +506,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
userIntegrations.NtfyUsername,
userIntegrations.NtfyPassword,
userIntegrations.NtfyIconURL,
userIntegrations.NtfyInternalLinks,
feed.NtfyPriority,
)
@@ -535,6 +537,22 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
}
}
if userIntegrations.DiscordEnabled {
slog.Debug("Sending new entries to Discord",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
client := discord.NewClient(
userIntegrations.DiscordWebhookLink,
)
if err := client.SendDiscordMsg(feed, entries); err != nil {
slog.Warn("Unable to send new entries to Discord", slog.Any("error", err))
}
}
// Integrations that only support sending individual entries
if userIntegrations.TelegramBotEnabled {
for _, entry := range entries {
+15 -3
View File
@@ -9,8 +9,10 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
@@ -22,14 +24,15 @@ const (
type Client struct {
ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string
ntfyInternalLinks bool
ntfyPriority int
}
func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string, ntfyPriority int) *Client {
func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string, ntfyInternalLinks bool, ntfyPriority int) *Client {
if ntfyURL == "" {
ntfyURL = defaultNtfyURL
}
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyPriority}
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyInternalLinks, ntfyPriority}
}
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
@@ -46,12 +49,21 @@ func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
ntfyMessage.Icon = c.ntfyIconURL
}
if c.ntfyInternalLinks {
url, err := url.Parse(config.Opts.BaseURL())
if err != nil {
slog.Error("Unable to parse base URL", slog.Any("error", err))
} else {
ntfyMessage.Click = fmt.Sprintf("%s%s%d", url, "/unread/entry/", entry.ID)
}
}
slog.Debug("Sending Ntfy message",
slog.String("url", c.ntfyURL),
slog.String("topic", c.ntfyTopic),
slog.Int("priority", ntfyMessage.Priority),
slog.String("message", ntfyMessage.Message),
slog.String("entry_url", entry.URL),
slog.String("entry_url", ntfyMessage.Click),
)
if err := c.makeRequest(ntfyMessage); err != nil {
+4 -1
View File
@@ -435,7 +435,7 @@
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket-Konto",
"form.integration.wallabag_activate": "Einträge in Wallabag speichern",
"form.integration.wallabag_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.wallabag_endpoint": "Wallabag-URL",
"form.integration.wallabag_endpoint": "Wallabag-Basis-URL",
"form.integration.wallabag_client_id": "Wallabag-Client-ID",
"form.integration.wallabag_client_secret": "Wallabag-Client-Geheimnis",
"form.integration.wallabag_username": "Wallabag-Benutzername",
@@ -513,6 +513,9 @@
"form.integration.ntfy_username": "Ntfy-Benutzername (optional)",
"form.integration.ntfy_password": "Ntfy-Passwort (optional)",
"form.integration.ntfy_icon_url": "Ntfy-Symbol-URL (optional)",
"form.integration.ntfy_internal_links": "Interne Links beim Klicken verwenden (optional)",
"form.integration.discord_activate": "Einträge zu Discord pushen",
"form.integration.discord_webhook_link": "Discord-Webhook-URL",
"form.api_key.label.description": "API-Schlüsselbezeichnung",
"form.submit.loading": "Lade...",
"form.submit.saving": "Speichern...",
+4 -1
View File
@@ -435,7 +435,7 @@
"form.integration.pocket_connect_link": "Συνδέστε τον λογαριασμό Pocket σας",
"form.integration.wallabag_activate": "Αποθήκευση άρθρων στο Wallabag",
"form.integration.wallabag_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
"form.integration.wallabag_endpoint": "Τελικό σημείο Wallabag API ",
"form.integration.wallabag_endpoint": "Βασική διεύθυνση URL Wallabag",
"form.integration.wallabag_client_id": "Ταυτότητα πελάτη Wallabag",
"form.integration.wallabag_client_secret": "Wallabag Μυστικό Πελάτη",
"form.integration.wallabag_username": "Όνομα Χρήστη Wallabag",
@@ -513,6 +513,9 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.api_key.label.description": "Ετικέτα κλειδιού API",
"form.submit.loading": "Φόρτωση...",
"form.submit.saving": "Αποθήκευση...",
+4 -1
View File
@@ -433,7 +433,7 @@
"form.integration.pocket_connect_link": "Connect your Pocket account",
"form.integration.wallabag_activate": "Save entries to Wallabag",
"form.integration.wallabag_only_url": "Send only URL (instead of full content)",
"form.integration.wallabag_endpoint": "Wallabag API Endpoint",
"form.integration.wallabag_endpoint": "Wallabag Base URL",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_username": "Wallabag Username",
@@ -511,8 +511,11 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.api_key.label.description": "API Key Label",
"form.submit.loading": "Loading…",
"form.submit.saving": "Saving…",
+4 -1
View File
@@ -435,7 +435,7 @@
"form.integration.pocket_connect_link": "Conectar a la cuenta de Pocket",
"form.integration.wallabag_activate": "Enviar artículos a Wallabag",
"form.integration.wallabag_only_url": "Enviar solo URL (en lugar de contenido completo)",
"form.integration.wallabag_endpoint": "Acceso API de Wallabag",
"form.integration.wallabag_endpoint": "URL base de Wallabag",
"form.integration.wallabag_client_id": "ID de cliente de Wallabag",
"form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag",
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
@@ -513,6 +513,9 @@
"form.integration.ntfy_username": "Nombre de usuario de Ntfy (opcional)",
"form.integration.ntfy_password": "Contraseña de Ntfy (opcional)",
"form.integration.ntfy_icon_url": "URL del icono de Ntfy (opcional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "Enviar artículos a Discord",
"form.integration.discord_webhook_link": "URL de la Webhook de Discord",
"form.api_key.label.description": "Etiqueta de clave API",
"form.submit.loading": "Cargando...",
"form.submit.saving": "Guardando...",
+4 -1
View File
@@ -435,7 +435,7 @@
"form.integration.pocket_connect_link": "Yhdistä Pocket-tilisi",
"form.integration.wallabag_activate": "Tallenna artikkelit Wallabagiin",
"form.integration.wallabag_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
"form.integration.wallabag_endpoint": "Wallabag API -päätepiste",
"form.integration.wallabag_endpoint": "Wallabagin perus-URL-osoite",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_username": "Wallabag-käyttäjätunnus",
@@ -513,6 +513,9 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.api_key.label.description": "API Key Label",
"form.submit.loading": "Ladataan...",
"form.submit.saving": "Tallennetaan...",
+4 -1
View File
@@ -435,7 +435,7 @@
"form.integration.pocket_connect_link": "Connectez votre compte Pocket",
"form.integration.wallabag_activate": "Sauvegarder les articles vers Wallabag",
"form.integration.wallabag_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
"form.integration.wallabag_endpoint": "URL de l'API de Wallabag",
"form.integration.wallabag_endpoint": "URL de base de Wallabag",
"form.integration.wallabag_client_id": "Identifiant unique du client Wallabag",
"form.integration.wallabag_client_secret": "Clé secrète du client Wallabag",
"form.integration.wallabag_username": "Nom d'utilisateur de Wallabag",
@@ -513,6 +513,9 @@
"form.integration.ntfy_username": "Nom d'utilisateur Ntfy (optionnel)",
"form.integration.ntfy_password": "Mot de passe Ntfy (facultatif)",
"form.integration.ntfy_icon_url": "URL de l'icône Ntfy (facultatif)",
"form.integration.ntfy_internal_links": "Utiliser les liens internes vers Miniflux (facultatif)",
"form.integration.discord_activate": "Envoyer les articles vers Discord",
"form.integration.discord_webhook_link": "URL du Webhook Discord",
"form.api_key.label.description": "Libellé de la clé d'API",
"form.submit.loading": "Chargement...",
"form.submit.saving": "Sauvegarde en cours...",
+4 -1
View File
@@ -435,7 +435,7 @@
"form.integration.pocket_connect_link": "अपना पॉकेट खाता कनेक्ट करें",
"form.integration.wallabag_activate": "विषय सहेजें वालाबाग में ",
"form.integration.wallabag_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
"form.integration.wallabag_endpoint": "वालबैग एपीआई एंडपॉइंट",
"form.integration.wallabag_endpoint": "वल्लाबैग बेस यूआरएल",
"form.integration.wallabag_client_id": "वालाबैग क्लाइंट आईडी",
"form.integration.wallabag_client_secret": "वालाबैग क्लाइंट सीक्रेट",
"form.integration.wallabag_username": "वालाबैग उपयोगकर्ता नाम",
@@ -513,6 +513,9 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.api_key.label.description": "एपीआई कुंजी लेबल",
"form.submit.loading": "लोड हो रहा है...",
"form.submit.saving": "सहेजा जा रहा है...",
+4 -1
View File
@@ -425,7 +425,7 @@
"form.integration.pocket_connect_link": "Hubungkan akun Pocket Anda",
"form.integration.wallabag_activate": "Simpan artikel ke Wallabag",
"form.integration.wallabag_only_url": "Kirim hanya URL (alih-alih konten penuh)",
"form.integration.wallabag_endpoint": "Titik URL API Wallabag",
"form.integration.wallabag_endpoint": "URL Dasar Wallabag",
"form.integration.wallabag_client_id": "ID Klien Wallabag",
"form.integration.wallabag_client_secret": "Rahasia Klien Wallabag",
"form.integration.wallabag_username": "Nama Pengguna Wallabag",
@@ -503,6 +503,9 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.api_key.label.description": "Label Kunci API",
"form.submit.loading": "Memuat...",
"form.submit.saving": "Menyimpan...",
+4 -1
View File
@@ -434,7 +434,7 @@
"form.integration.pocket_access_token": "Access token dell'account Pocket",
"form.integration.pocket_connect_link": "Collega il tuo account Pocket",
"form.integration.wallabag_activate": "Salva gli articoli su Wallabag",
"form.integration.wallabag_endpoint": "Endpoint dell'API di Wallabag",
"form.integration.wallabag_endpoint": "URL di base di Wallabagg",
"form.integration.wallabag_only_url": "Invia solo URL (invece del contenuto completo)",
"form.integration.wallabag_client_id": "Client ID dell'account Wallabag",
"form.integration.wallabag_client_secret": "Client secret dell'account Wallabag",
@@ -514,6 +514,9 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.submit.loading": "Caricamento in corso...",
"form.submit.saving": "Salvataggio in corso...",
"time_elapsed.not_yet": "non ancora",
+4 -1
View File
@@ -425,7 +425,7 @@
"form.integration.pocket_connect_link": "Pocket account に接続",
"form.integration.wallabag_activate": "Wallabag に記事を保存する",
"form.integration.wallabag_only_url": "URL のみを送信 (完全なコンテンツではなく)",
"form.integration.wallabag_endpoint": "Wallabag の API Endpoint",
"form.integration.wallabag_endpoint": "ワラバッグベースURL",
"form.integration.wallabag_client_id": "Wallabag の Client ID",
"form.integration.wallabag_client_secret": "Wallabag の Client Secret",
"form.integration.wallabag_username": "Wallabag のユーザー名",
@@ -503,6 +503,9 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.api_key.label.description": "API キーラベル",
"form.submit.loading": "読み込み中…",
"form.submit.saving": "保存中…",
+4 -1
View File
@@ -435,7 +435,7 @@
"form.integration.pocket_connect_link": "Verbind je Pocket-account",
"form.integration.wallabag_activate": "Artikelen opslaan in Wallabag",
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_endpoint": "Wallabag basis-URL",
"form.integration.wallabag_client_id": "Wallabag Client-ID",
"form.integration.wallabag_client_secret": "Wallabag Client-Secret",
"form.integration.wallabag_username": "Wallabag gebruikersnaam",
@@ -513,6 +513,9 @@
"form.integration.ntfy_username": "Ntfy gebruikersnaam (optioneel)",
"form.integration.ntfy_password": "Ntfy wachtwoord (optioneel)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optioneel)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "Artikelen opslaan in Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.api_key.label.description": "API-sleutel omschrijving",
"form.submit.loading": "Laden...",
"form.submit.saving": "Opslaan...",
+5 -2
View File
@@ -445,7 +445,7 @@
"form.integration.pocket_connect_link": "Połącz swoje konto Pocket",
"form.integration.wallabag_activate": "Zapisuj wpisy w Wallabag",
"form.integration.wallabag_only_url": "Przesyłaj tylko adres URL (zamiast pełnej treści)",
"form.integration.wallabag_endpoint": "Punkt końcowy API Wallabag",
"form.integration.wallabag_endpoint": "Podstawowy adres URL Wallabag",
"form.integration.wallabag_client_id": "Identyfikator klienta Wallabag",
"form.integration.wallabag_client_secret": "Tajny klucz klienta Wallabag",
"form.integration.wallabag_username": "Login do Wallabag",
@@ -500,7 +500,7 @@
"form.integration.raindrop_collection_id": "Identyfikator kolekcji",
"form.integration.raindrop_tags": "Znaczniki (oddzielone przecinkami)",
"form.integration.readeck_activate": "Zapisuj wpisy do Readeck",
"form.integration.readeck_endpoint": "Punkt końcowy API Readeck",
"form.integration.readeck_endpoint": "Adres URL Readeck",
"form.integration.readeck_api_key": "Tajny klucz API Readeck",
"form.integration.readeck_labels": "Etykiety Readeck",
"form.integration.readeck_only_url": "Wysyłaj tylko adres URL (zamiast pełnej treści)",
@@ -523,6 +523,9 @@
"form.integration.ntfy_username": "Login do ntfy (opcjonalny)",
"form.integration.ntfy_password": "Hasło do ntfy (opcjonalne)",
"form.integration.ntfy_icon_url": "Adres URL ikony ntfy (opcjonalny)",
"form.integration.ntfy_internal_links": "Używaj łączy wewnętrznych po kliknięciu (opcjonalnie)",
"form.integration.discord_activate": "Przesyłaj wpisy do Discord",
"form.integration.discord_webhook_link": "Adres URL Webhook Discord",
"form.api_key.label.description": "Etykieta klucza API",
"form.submit.loading": "Ładowanie…",
"form.submit.saving": "Zapisywanie…",
+4 -1
View File
@@ -435,7 +435,7 @@
"form.integration.pocket_connect_link": "Conectar a conta do Pocket",
"form.integration.wallabag_activate": "Salvar itens no Wallabag",
"form.integration.wallabag_only_url": "Enviar apenas URL (em vez de conteúdo completo)",
"form.integration.wallabag_endpoint": "Endpoint da API do Wallabag",
"form.integration.wallabag_endpoint": "URL base do Wallabag",
"form.integration.wallabag_client_id": "ID de cliente (Client ID) do Wallabag",
"form.integration.wallabag_client_secret": "Segredo do cliente (Client Secret) do Wallabag",
"form.integration.wallabag_username": "Nome de usuário do Wallabag",
@@ -513,6 +513,9 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.api_key.label.description": "Etiqueta da chave de API",
"form.submit.loading": "Carregando...",
"form.submit.saving": "Salvando...",
+4 -1
View File
@@ -445,7 +445,7 @@
"form.integration.pocket_connect_link": "Подключить аккаунт Pocket",
"form.integration.wallabag_only_url": "Отправлять только ссылку (без содержимого)",
"form.integration.wallabag_activate": "Сохранять статьи в Wallabag",
"form.integration.wallabag_endpoint": "Конечная точка Wallabag API",
"form.integration.wallabag_endpoint": "URL-адрес базы Валлабаг",
"form.integration.wallabag_client_id": "Номер клиента Wallabag",
"form.integration.wallabag_client_secret": "Секретный код клиента Wallabag",
"form.integration.wallabag_username": "Имя пользователя Wallabag",
@@ -523,6 +523,9 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "Отправить статьи в Discord",
"form.integration.discord_webhook_link": "Ссылка на Discord Webhook",
"form.api_key.label.description": "Описание API-ключа",
"form.submit.loading": "Загрузка…",
"form.submit.saving": "Сохранение…",
+4 -1
View File
@@ -265,7 +265,7 @@
"form.integration.wallabag_activate": "Makaleleri Wallabag'e kaydet",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_endpoint": "Wallabag API Uç Noktası",
"form.integration.wallabag_endpoint": "Wallabag Üssü URL",
"form.integration.wallabag_only_url": "Yalnızca URL gönder (tam makale yerine)",
"form.integration.wallabag_password": "Wallabag Parolası",
"form.integration.wallabag_username": "Wallabag Kullanıcı Adı",
@@ -279,6 +279,7 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
@@ -288,6 +289,8 @@
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.discord_activate": "Makaleleri Discord'a gönder",
"form.integration.discord_webhook_link": "Discord hizmet Webhook'lerinin virgülle ayrılmış listesi",
"form.prefs.fieldset.application_settings": "Uygulama Ayarları",
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
+4 -1
View File
@@ -445,7 +445,7 @@
"form.integration.pocket_connect_link": "Підключити ваш обліковий запис Pocket",
"form.integration.wallabag_activate": "Зберігати статті до Wallabag",
"form.integration.wallabag_only_url": "Надіслати лише URL (замість повного вмісту)",
"form.integration.wallabag_endpoint": "Wallabag API Endpoint",
"form.integration.wallabag_endpoint": "Базова URL-адреса Wallabag",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_username": "Ім’я користувача Wallabag",
@@ -523,6 +523,9 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.api_key.label.description": "Назва ключа API",
"form.submit.loading": "Завантаження...",
"form.submit.saving": "Зберігаю...",
+4 -1
View File
@@ -425,7 +425,7 @@
"form.integration.pocket_connect_link": "连接您的 Pocket 帐户",
"form.integration.wallabag_activate": "保存文章到 Wallabag",
"form.integration.wallabag_only_url": "仅发送 URL(而不是完整内容)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_endpoint": "Wallabag 基本 URL",
"form.integration.wallabag_client_id": "Wallabag 客户端 ID",
"form.integration.wallabag_client_secret": "Wallabag 客户端 密钥",
"form.integration.wallabag_username": "Wallabag 用户名",
@@ -503,6 +503,9 @@
"form.integration.ntfy_username": "Ntfy用户名(可选)",
"form.integration.ntfy_password": "Ntfy密码(可选)",
"form.integration.ntfy_icon_url": "Ntfy图标URL(可选)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "将新文章推送到 Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.api_key.label.description": "API密钥标签",
"form.submit.loading": "载入中…",
"form.submit.saving": "保存中…",
+4 -1
View File
@@ -425,7 +425,7 @@
"form.integration.pocket_connect_link": "連線您的 Pocket 帳戶",
"form.integration.wallabag_activate": "儲存文章到 Wallabag",
"form.integration.wallabag_only_url": "仅发送 URL(而不是完整内容)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_endpoint": "Wallabag 基本 URL",
"form.integration.wallabag_client_id": "Wallabag 客戶端 ID",
"form.integration.wallabag_client_secret": "Wallabag 客戶端 Secret",
"form.integration.wallabag_username": "Wallabag 使用者名稱",
@@ -503,6 +503,9 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.discord_activate": "推送文章到 Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.api_key.label.description": "API金鑰標籤",
"form.submit.loading": "載入中…",
"form.submit.saving": "儲存中…",
+3
View File
@@ -104,6 +104,9 @@ type Integration struct {
NtfyUsername string
NtfyPassword string
NtfyIconURL string
NtfyInternalLinks bool
CuboxEnabled bool
CuboxAPILink string
DiscordEnabled bool
DiscordWebhookLink string
}
+2 -6
View File
@@ -6,7 +6,6 @@ package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"crypto/sha256"
"encoding/base64"
"io"
"golang.org/x/oauth2"
@@ -33,17 +32,14 @@ func (u *Authorization) CodeVerifier() string {
func GenerateAuthorization(config *oauth2.Config) *Authorization {
codeVerifier := crypto.GenerateRandomStringHex(32)
sha2 := sha256.New()
io.WriteString(sha2, codeVerifier)
codeChallenge := base64.RawURLEncoding.EncodeToString(sha2.Sum(nil))
sum := sha256.Sum256([]byte(codeVerifier))
state := crypto.GenerateRandomStringHex(24)
authUrl := config.AuthCodeURL(
state,
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge", base64.RawURLEncoding.EncodeToString(sum[:])),
)
return &Authorization{
-2
View File
@@ -9,7 +9,6 @@ import (
"strings"
"miniflux.app/v2/internal/reader/media"
"miniflux.app/v2/internal/reader/sanitizer"
)
// The "atom:feed" element is the document (i.e., top-level) element of
@@ -188,7 +187,6 @@ func (a *Atom10Text) Title() string {
content = a.CharData
}
content = sanitizer.StripTags(content)
return strings.TrimSpace(content)
}
+15 -3
View File
@@ -492,6 +492,10 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
</title>
<link href="http://example.org/c"/>
</entry>
<entry>
<title type="html"><![CDATA[Test with self-closing &lt;tag&gt;]]></title>
<link href="http://example.org/d"/>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
@@ -499,7 +503,11 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
t.Fatal(err)
}
if feed.Entries[0].Title != "Code Test" {
if len(feed.Entries) != 4 {
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Title != "<code>Code</code> Test" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
@@ -510,6 +518,10 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
if feed.Entries[2].Title != "Entry title with space around CDATA" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[2].Title)
}
if feed.Entries[3].Title != "Test with self-closing <tag>" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[3].Title)
}
}
func TestParseEntryWithXHTMLTitle(t *testing.T) {
@@ -537,7 +549,7 @@ func TestParseEntryWithXHTMLTitle(t *testing.T) {
t.Fatal(err)
}
if feed.Entries[0].Title != `This is XHTML content.` {
if feed.Entries[0].Title != `This is <b>XHTML</b> content.` {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
@@ -643,7 +655,7 @@ func TestParseEntryWithDoubleEncodedEntitiesTitle(t *testing.T) {
t.Fatal(err)
}
if feed.Entries[0].Title != `'AT&T'` {
if feed.Entries[0].Title != `&#39;AT&amp;T&#39;` {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
@@ -89,6 +89,15 @@ func (r *ResponseHandler) IsModified(lastEtagValue, lastModifiedValue string) bo
return true
}
func (r *ResponseHandler) IsRedirect() bool {
return r.httpResponse != nil &&
(r.httpResponse.StatusCode == http.StatusMovedPermanently ||
r.httpResponse.StatusCode == http.StatusFound ||
r.httpResponse.StatusCode == http.StatusSeeOther ||
r.httpResponse.StatusCode == http.StatusTemporaryRedirect ||
r.httpResponse.StatusCode == http.StatusPermanentRedirect)
}
func (r *ResponseHandler) Close() {
if r.httpResponse != nil && r.httpResponse.Body != nil && r.clientErr == nil {
r.httpResponse.Body.Close()
+22 -18
View File
@@ -7,33 +7,37 @@ package readingtime
import (
"math"
"strings"
"unicode"
"unicode/utf8"
"miniflux.app/v2/internal/reader/sanitizer"
"github.com/abadojack/whatlanggo"
)
// EstimateReadingTime returns the estimated reading time of an article in minute.
func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed int) int {
sanitizedContent := sanitizer.StripTags(content)
truncationPoint := min(len(sanitizedContent), 50)
// Litterature on language detection says that around 100 signes is enough, we're safe here.
truncationPoint := min(len(sanitizedContent), 250)
// We're only interested in identifying Japanse/Chinese/Korean
options := whatlanggo.Options{
Whitelist: map[whatlanggo.Lang]bool{
whatlanggo.Jpn: true,
whatlanggo.Cmn: true,
whatlanggo.Kor: true,
},
}
langInfo := whatlanggo.DetectWithOptions(sanitizedContent[:truncationPoint], options)
if langInfo.IsReliable() {
if isCJK(sanitizedContent[:truncationPoint]) {
return int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))
}
nbOfWords := len(strings.Fields(sanitizedContent))
return int(math.Ceil(float64(nbOfWords) / float64(defaultReadingSpeed)))
return int(math.Ceil(float64(len(strings.Fields(sanitizedContent))) / float64(defaultReadingSpeed)))
}
func isCJK(text string) bool {
totalCJK := 0
for _, r := range text[:min(len(text), 50)] {
if unicode.Is(unicode.Han, r) ||
unicode.Is(unicode.Hangul, r) ||
unicode.Is(unicode.Hiragana, r) ||
unicode.Is(unicode.Katakana, r) ||
unicode.Is(unicode.Yi, r) ||
unicode.Is(unicode.Bopomofo, r) {
totalCJK++
}
}
// if at least 50% of the text is CJK, odds are that the text is in CJK.
return totalCJK > len(text)/50
}
@@ -11,6 +11,7 @@ import (
"net/url"
"regexp"
"strings"
"unicode"
"miniflux.app/v2/internal/config"
@@ -26,6 +27,24 @@ var (
textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
)
// titlelize returns a copy of the string s with all Unicode letters that begin words
// mapped to their Unicode title case.
func titlelize(s string) string {
// A closure is used here to remember the previous character
// so that we can check if there is a space preceding the current
// character.
previous := ' '
return strings.Map(
func(current rune) rune {
if unicode.IsSpace(previous) {
previous = current
return unicode.ToTitle(current)
}
previous = current
return current
}, strings.ToLower(s))
}
func addImageTitle(entryContent string) string {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
if err != nil {
@@ -436,3 +455,55 @@ func removeTables(entryContent string) string {
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
return output
}
func fixGhostCards(entryContent string) string {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
if err != nil {
return entryContent
}
const cardSelector = "figure.kg-card"
var currentList *goquery.Selection
doc.Find(cardSelector).Each(func(i int, s *goquery.Selection) {
title := s.Find(".kg-bookmark-title").First().Text()
author := s.Find(".kg-bookmark-author").First().Text()
href := s.Find("a.kg-bookmark-container").First().AttrOr("href", "")
// if there is no link or title, skip processing
if href == "" || title == "" {
return
}
link := ""
if author == "" || strings.HasSuffix(title, author) {
link = fmt.Sprintf("<a href=\"%s\">%s</a>", href, title)
} else {
link = fmt.Sprintf("<a href=\"%s\">%s - %s</a>", href, title, author)
}
next := s.Next()
// if the next element is also a card, start a list
if next.Is(cardSelector) && currentList == nil {
currentList = s.BeforeHtml("<ul></ul>").Prev()
}
if currentList != nil {
// add this card to the list, then delete it
currentList.AppendHtml("<li>" + link + "</li>")
s.Remove()
} else {
// replace single card
s.ReplaceWithHtml(link)
}
// if the next element is not a card, start a new list
if !next.Is(cardSelector) && currentList != nil {
currentList = nil
}
})
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
return strings.TrimSpace(output)
}
+3 -4
View File
@@ -11,9 +11,6 @@ import (
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type rule struct {
@@ -94,7 +91,9 @@ func (rule rule) applyRule(entryURL string, entry *model.Entry) {
case "remove_tables":
entry.Content = removeTables(entry.Content)
case "remove_clickbait":
entry.Title = cases.Title(language.English).String(strings.ToLower(entry.Title))
entry.Title = titlelize(entry.Title)
case "fix_ghost_cards":
entry.Content = fixGhostCards(entry.Content)
}
}
+228
View File
@@ -703,3 +703,231 @@ func TestAddImageTitle(t *testing.T) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestFixGhostCard(t *testing.T) {
testEntry := &model.Entry{
Title: `A title`,
Content: `<figure class="kg-card kg-bookmark-card">
<a class="kg-bookmark-container" href="https://example.org/article">
<div class="kg-bookmark-content">
<div class="kg-bookmark-title">Example Article</div>
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
<div class="kg-bookmark-metadata">
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
<span class="kg-bookmark-author">Example</span>
<span class="kg-bookmark-publisher">Test Author</span>
</div>
</div>
<div class="kg-bookmark-thumbnail">
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
</div>
</a>
</figure>`,
}
controlEntry := &model.Entry{
Title: `A title`,
Content: `<a href="https://example.org/article">Example Article - Example</a>`,
}
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestFixGhostCardNoCard(t *testing.T) {
testEntry := &model.Entry{
Title: `A title`,
Content: `<a href="https://example.org/article">Example Article - Example</a>`,
}
controlEntry := &model.Entry{
Title: `A title`,
Content: `<a href="https://example.org/article">Example Article - Example</a>`,
}
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestFixGhostCardInvalidCard(t *testing.T) {
testEntry := &model.Entry{
Title: `A title`,
Content: `<figure class="kg-card kg-bookmark-card">
<a href="https://example.org/article">This card does not have the required fields</a>
</figure>`,
}
controlEntry := &model.Entry{
Title: `A title`,
Content: `<figure class="kg-card kg-bookmark-card">
<a href="https://example.org/article">This card does not have the required fields</a>
</figure>`,
}
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestFixGhostCardMissingAuthor(t *testing.T) {
testEntry := &model.Entry{
Title: `A title`,
Content: `<figure class="kg-card kg-bookmark-card">
<a class="kg-bookmark-container" href="https://example.org/article">
<div class="kg-bookmark-content">
<div class="kg-bookmark-title">Example Article</div>
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
</div>
<div class="kg-bookmark-thumbnail">
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
</div>
</a>
</figure>`,
}
controlEntry := &model.Entry{
Title: `A title`,
Content: `<a href="https://example.org/article">Example Article</a>`,
}
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestFixGhostCardDuplicatedAuthor(t *testing.T) {
testEntry := &model.Entry{
Title: `A title`,
Content: `<figure class="kg-card kg-bookmark-card">
<a class="kg-bookmark-container" href="https://example.org/article">
<div class="kg-bookmark-content">
<div class="kg-bookmark-title">Example Article - Example</div>
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
<div class="kg-bookmark-metadata">
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
<span class="kg-bookmark-author">Example</span>
<span class="kg-bookmark-publisher">Test Author</span>
</div>
</div>
<div class="kg-bookmark-thumbnail">
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
</div>
</a>
</figure>`,
}
controlEntry := &model.Entry{
Title: `A title`,
Content: `<a href="https://example.org/article">Example Article - Example</a>`,
}
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestFixGhostCardMultiple(t *testing.T) {
testEntry := &model.Entry{
Title: `A title`,
Content: `<figure class="kg-card kg-bookmark-card">
<a class="kg-bookmark-container" href="https://example.org/article1">
<div class="kg-bookmark-content">
<div class="kg-bookmark-title">Example Article 1 - Example</div>
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
<div class="kg-bookmark-metadata">
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
<span class="kg-bookmark-author">Example</span>
<span class="kg-bookmark-publisher">Test Author</span>
</div>
</div>
<div class="kg-bookmark-thumbnail">
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
</div>
</a>
</figure>
<figure class="kg-card kg-bookmark-card">
<a class="kg-bookmark-container" href="https://example.org/article2">
<div class="kg-bookmark-content">
<div class="kg-bookmark-title">Example Article 2 - Example</div>
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
<div class="kg-bookmark-metadata">
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
<span class="kg-bookmark-author">Example</span>
<span class="kg-bookmark-publisher">Test Author</span>
</div>
</div>
<div class="kg-bookmark-thumbnail">
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
</div>
</a>
</figure>`,
}
controlEntry := &model.Entry{
Title: `A title`,
Content: `<ul><li><a href="https://example.org/article1">Example Article 1 - Example</a></li><li><a href="https://example.org/article2">Example Article 2 - Example</a></li></ul>`,
}
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestFixGhostCardMultipleSplit(t *testing.T) {
testEntry := &model.Entry{
Title: `A title`,
Content: `<figure class="kg-card kg-bookmark-card">
<a class="kg-bookmark-container" href="https://example.org/article1">
<div class="kg-bookmark-content">
<div class="kg-bookmark-title">Example Article 1 - Example</div>
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
<div class="kg-bookmark-metadata">
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
<span class="kg-bookmark-author">Example</span>
<span class="kg-bookmark-publisher">Test Author</span>
</div>
</div>
<div class="kg-bookmark-thumbnail">
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
</div>
</a>
</figure>
<p>This separates the two cards</p>
<figure class="kg-card kg-bookmark-card">
<a class="kg-bookmark-container" href="https://example.org/article2">
<div class="kg-bookmark-content">
<div class="kg-bookmark-title">Example Article 2 - Example</div>
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
<div class="kg-bookmark-metadata">
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
<span class="kg-bookmark-author">Example</span>
<span class="kg-bookmark-publisher">Test Author</span>
</div>
</div>
<div class="kg-bookmark-thumbnail">
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
</div>
</a>
</figure>`,
}
controlEntry := &model.Entry{
Title: `A title`,
Content: `<a href="https://example.org/article1">Example Article 1 - Example</a>
<p>This separates the two cards</p>
<a href="https://example.org/article2">Example Article 2 - Example</a>`,
}
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
+1
View File
@@ -43,6 +43,7 @@ var (
"h4": {"id"},
"h5": {"id"},
"h6": {"id"},
"hr": {},
"iframe": {"width", "height", "frameborder", "src", "allowfullscreen"},
"img": {"alt", "title", "src", "srcset", "sizes", "width", "height"},
"ins": {},
@@ -5,8 +5,11 @@ package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
import (
"os"
"strings"
"testing"
"golang.org/x/net/html"
"miniflux.app/v2/internal/config"
)
@@ -35,6 +38,28 @@ func BenchmarkSanitize(b *testing.B) {
}
}
func FuzzSanitizer(f *testing.F) {
f.Fuzz(func(t *testing.T, orig string) {
tok := html.NewTokenizer(strings.NewReader(orig))
i := 0
for tok.Next() != html.ErrorToken {
i++
}
out := Sanitize("", orig)
tok = html.NewTokenizer(strings.NewReader(out))
j := 0
for tok.Next() != html.ErrorToken {
j++
}
if j > i {
t.Errorf("Got more html tokens in the sanitized html.")
}
})
}
func TestValidInput(t *testing.T) {
input := `<p>This is a <strong>text</strong> with an image: <img src="http://example.org/" alt="Test" loading="lazy">.</p>`
output := Sanitize("http://example.org/", input)
+10 -1
View File
@@ -228,8 +228,17 @@ func (f *SubscriptionFinder) FindSubscriptionsFromWellKnownURLs(websiteURL strin
localizedError := responseHandler.LocalizedError()
responseHandler.Close()
// Do not add redirections to the possible list of subscriptions to avoid confusion.
if responseHandler.IsRedirect() {
slog.Debug("Ignore URL redirection during feed discovery", slog.String("fullURL", fullURL))
continue
}
if localizedError != nil {
slog.Debug("Unable to subscribe", slog.String("fullURL", fullURL), slog.Any("error", localizedError.Error()))
slog.Debug("Ignore invalid feed URL during feed discovery",
slog.String("fullURL", fullURL),
slog.Any("error", localizedError.Error()),
)
continue
}
+3 -3
View File
@@ -112,13 +112,13 @@ func (e *EntryPaginationBuilder) getPrevNextID(tx *sql.Tx) (prevID int64, nextID
WITH entry_pagination AS (
SELECT
e.id,
lag(e.id) over (order by e.%[1]s asc, e.id desc) as prev_id,
lead(e.id) over (order by e.%[1]s asc, e.id desc) as next_id
lag(e.id) over (order by e.%[1]s asc, e.created_at asc, e.id desc) as prev_id,
lead(e.id) over (order by e.%[1]s asc, e.created_at asc, e.id desc) as next_id
FROM entries AS e
JOIN feeds AS f ON f.id=e.feed_id
JOIN categories c ON c.id = f.category_id
WHERE %[2]s
ORDER BY e.%[1]s asc, e.id desc
ORDER BY e.%[1]s asc, e.created_at asc, e.id desc
)
SELECT prev_id, next_id FROM entry_pagination AS ep WHERE %[3]s;
`
+18 -5
View File
@@ -208,8 +208,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
ntfy_username,
ntfy_password,
ntfy_icon_url,
ntfy_internal_links,
cubox_enabled,
cubox_api_link
cubox_api_link,
discord_enabled,
discord_webhook_link
FROM
integrations
WHERE
@@ -316,8 +319,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
&integration.NtfyUsername,
&integration.NtfyPassword,
&integration.NtfyIconURL,
&integration.NtfyInternalLinks,
&integration.CuboxEnabled,
&integration.CuboxAPILink,
&integration.DiscordEnabled,
&integration.DiscordWebhookLink,
)
switch {
case err == sql.ErrNoRows:
@@ -433,10 +439,13 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
ntfy_username=$96,
ntfy_password=$97,
ntfy_icon_url=$98,
cubox_enabled=$99,
cubox_api_link=$100
ntfy_internal_links=$99,
cubox_enabled=$100,
cubox_api_link=$101,
discord_enabled=$102,
discord_webhook_link=$103
WHERE
user_id=$101
user_id=$104
`
_, err := s.db.Exec(
query,
@@ -538,8 +547,11 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
integration.NtfyUsername,
integration.NtfyPassword,
integration.NtfyIconURL,
integration.NtfyInternalLinks,
integration.CuboxEnabled,
integration.CuboxAPILink,
integration.DiscordEnabled,
integration.DiscordWebhookLink,
integration.UserID,
)
@@ -580,7 +592,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
omnivore_enabled='t' OR
raindrop_enabled='t' OR
betula_enabled='t' OR
cubox_enabled='t'
cubox_enabled='t' OR
discord_enabled='t'
)
`
if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {
@@ -73,6 +73,22 @@
</div>
</details>
<details {{ if .form.DiscordEnabled }}open{{ end }}>
<summary>Discord</summary>
<div class="form-section">
<label>
<input type="checkbox" name="discord_enabled" value="1" {{ if .form.DiscordEnabled }}checked{{ end }}> {{ t "form.integration.discord_activate" }}
</label>
<label for="form-discord-webhook-link">{{ t "form.integration.discord_webhook_link" }}</label>
<input type="url" name="discord_webhook_link" id="form-discord-webhook-link" value="{{ .form.DiscordWebhookLink }}" placeholder="https://discord.com/api/webhooks/xxx/xxx" spellcheck="false">
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
</div>
</div>
</details>
<details {{ if .form.EspialEnabled }}open{{ end }}>
<summary>Espial</summary>
<div class="form-section">
@@ -300,6 +316,10 @@
<label for="form-ntfy-icon-url">{{ t "form.integration.ntfy_icon_url" }}</label>
<input type="url" name="ntfy_icon_url" id="form-ntfy-icon-url" value="{{ .form.NtfyIconURL }}" spellcheck="false">
<label>
<input type="checkbox" name="ntfy_internal_links" value="1" {{ if .form.NtfyInternalLinks }}checked{{ end }}> {{ t "form.integration.ntfy_internal_links" }}
</label>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
</div>
@@ -561,7 +581,7 @@
</label>
<label for="form-wallabag-url">{{ t "form.integration.wallabag_endpoint" }}</label>
<input type="url" name="wallabag_url" id="form-wallabag-url" value="{{ .form.WallabagURL }}" placeholder="http://v2.wallabag.org/" spellcheck="false">
<input type="url" name="wallabag_url" id="form-wallabag-url" value="{{ .form.WallabagURL }}" spellcheck="false">
<label for="form-wallabag-client-id">{{ t "form.integration.wallabag_client_id" }}</label>
<input type="text" name="wallabag_client_id" id="form-wallabag-client-id" value="{{ .form.WallabagClientID }}" spellcheck="false">
@@ -115,14 +115,14 @@
<label><input type="checkbox" name="show_reading_time" value="1" {{ if .form.ShowReadingTime }}checked{{ end }}> {{ t "form.prefs.label.show_reading_time" }}</label>
<label><input type="radio" name="mark_read_behavior" value="{{ .const.NoAutoMarkAsRead }}"
{{ if eq .form.MarkReadBehavior .const.NoAutoMarkAsRead }}checked{{end}} > {{ t "form.prefs.label.mark_read_manually" }}</label>
<label><input type="radio" name="mark_read_behavior" value="{{ .const.MarkAsReadOnView }}"
{{ if eq .form.MarkReadBehavior .const.MarkAsReadOnView }}checked{{end}} > {{ t "form.prefs.label.mark_read_on_view" }}</label>
<label><input type="radio" name="mark_read_behavior" value="{{ .const.MarkAsReadOnViewButWaitForPlayerCompletion }}"
{{ if eq .form.MarkReadBehavior .const.MarkAsReadOnViewButWaitForPlayerCompletion }}checked{{end}}> {{ t "form.prefs.label.mark_read_on_view_or_media_completion" }}</label>
<label><input type="radio" name="mark_read_behavior" value="{{ .const.MarkAsReadOnlyOnPlayerCompletion }}"
{{ if eq .form.MarkReadBehavior .const.MarkAsReadOnlyOnPlayerCompletion }}checked{{end}} > {{ t "form.prefs.label.mark_read_on_media_completion" }}</label>
<label><input type="radio" name="mark_read_behavior" value="{{ .readBehaviors.NoAutoMarkAsRead }}"
{{ if eq .form.MarkReadBehavior .readBehaviors.NoAutoMarkAsRead }}checked{{end}} > {{ t "form.prefs.label.mark_read_manually" }}</label>
<label><input type="radio" name="mark_read_behavior" value="{{ .readBehaviors.MarkAsReadOnView }}"
{{ if eq .form.MarkReadBehavior .readBehaviors.MarkAsReadOnView }}checked{{end}} > {{ t "form.prefs.label.mark_read_on_view" }}</label>
<label><input type="radio" name="mark_read_behavior" value="{{ .readBehaviors.MarkAsReadOnViewButWaitForPlayerCompletion }}"
{{ if eq .form.MarkReadBehavior .readBehaviors.MarkAsReadOnViewButWaitForPlayerCompletion }}checked{{end}}> {{ t "form.prefs.label.mark_read_on_view_or_media_completion" }}</label>
<label><input type="radio" name="mark_read_behavior" value="{{ .readBehaviors.MarkAsReadOnlyOnPlayerCompletion }}"
{{ if eq .form.MarkReadBehavior .readBehaviors.MarkAsReadOnlyOnPlayerCompletion }}checked{{end}} > {{ t "form.prefs.label.mark_read_on_media_completion" }}</label>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
+1
View File
@@ -26,6 +26,7 @@ func (h *handler) showStarredPage(w http.ResponseWriter, r *http.Request) {
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStarred(true)
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithSorting("id", user.EntryDirection)
builder.WithOffset(offset)
builder.WithLimit(user.EntriesPerPage)
+1
View File
@@ -37,6 +37,7 @@ func (h *handler) showCategoryEntriesPage(w http.ResponseWriter, r *http.Request
builder := h.store.NewEntryQueryBuilder(user.ID)
builder.WithCategoryID(category.ID)
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithSorting("id", user.EntryDirection)
builder.WithStatus(model.EntryStatusUnread)
builder.WithOffset(offset)
builder.WithLimit(user.EntriesPerPage)
+1
View File
@@ -37,6 +37,7 @@ func (h *handler) showCategoryEntriesAllPage(w http.ResponseWriter, r *http.Requ
builder := h.store.NewEntryQueryBuilder(user.ID)
builder.WithCategoryID(category.ID)
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithSorting("id", user.EntryDirection)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithOffset(offset)
builder.WithLimit(user.EntriesPerPage)
+1
View File
@@ -37,6 +37,7 @@ func (h *handler) showCategoryEntriesStarredPage(w http.ResponseWriter, r *http.
builder := h.store.NewEntryQueryBuilder(user.ID)
builder.WithCategoryID(category.ID)
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithSorting("id", user.EntryDirection)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStarred(true)
builder.WithOffset(offset)
+1
View File
@@ -38,6 +38,7 @@ func (h *handler) showFeedEntriesPage(w http.ResponseWriter, r *http.Request) {
builder.WithFeedID(feed.ID)
builder.WithStatus(model.EntryStatusUnread)
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithSorting("id", user.EntryDirection)
builder.WithOffset(offset)
builder.WithLimit(user.EntriesPerPage)
+1
View File
@@ -38,6 +38,7 @@ func (h *handler) showFeedEntriesAllPage(w http.ResponseWriter, r *http.Request)
builder.WithFeedID(feed.ID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithSorting("id", user.EntryDirection)
builder.WithOffset(offset)
builder.WithLimit(user.EntriesPerPage)
+9
View File
@@ -110,8 +110,11 @@ type IntegrationForm struct {
NtfyUsername string
NtfyPassword string
NtfyIconURL string
NtfyInternalLinks bool
CuboxEnabled bool
CuboxAPILink string
DiscordEnabled bool
DiscordWebhookLink string
}
// Merge copy form values to the model.
@@ -211,8 +214,11 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
integration.NtfyUsername = i.NtfyUsername
integration.NtfyPassword = i.NtfyPassword
integration.NtfyIconURL = i.NtfyIconURL
integration.NtfyInternalLinks = i.NtfyInternalLinks
integration.CuboxEnabled = i.CuboxEnabled
integration.CuboxAPILink = i.CuboxAPILink
integration.DiscordEnabled = i.DiscordEnabled
integration.DiscordWebhookLink = i.DiscordWebhookLink
}
// NewIntegrationForm returns a new IntegrationForm.
@@ -315,8 +321,11 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
NtfyUsername: r.FormValue("ntfy_username"),
NtfyPassword: r.FormValue("ntfy_password"),
NtfyIconURL: r.FormValue("ntfy_icon_url"),
NtfyInternalLinks: r.FormValue("ntfy_internal_links") == "1",
CuboxEnabled: r.FormValue("cubox_enabled") == "1",
CuboxAPILink: r.FormValue("cubox_api_link"),
DiscordEnabled: r.FormValue("discord_enabled") == "1",
DiscordWebhookLink: r.FormValue("discord_webhook_link"),
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// MarkReadBehavior list all possible behaviors for automatically marking an entry as read
type MarkReadBehavior string
var (
const (
NoAutoMarkAsRead MarkReadBehavior = "no-auto"
MarkAsReadOnView MarkReadBehavior = "on-view"
MarkAsReadOnViewButWaitForPlayerCompletion MarkReadBehavior = "on-view-but-wait-for-player-completion"
+3
View File
@@ -124,8 +124,11 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
NtfyUsername: integration.NtfyUsername,
NtfyPassword: integration.NtfyPassword,
NtfyIconURL: integration.NtfyIconURL,
NtfyInternalLinks: integration.NtfyInternalLinks,
CuboxEnabled: integration.CuboxEnabled,
CuboxAPILink: integration.CuboxAPILink,
DiscordEnabled: integration.DiscordEnabled,
DiscordWebhookLink: integration.DiscordWebhookLink,
}
sess := session.New(h.store, request.SessionID(r))
+1 -2
View File
@@ -63,8 +63,7 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
view.Set("form", settingsForm)
// In order to keep the continuity between form and model, I pass adhoc constants to the view
view.Set("const", map[string]interface{}{
view.Set("readBehaviors", map[string]any{
"NoAutoMarkAsRead": form.NoAutoMarkAsRead,
"MarkAsReadOnView": form.MarkAsReadOnView,
"MarkAsReadOnViewButWaitForPlayerCompletion": form.MarkAsReadOnViewButWaitForPlayerCompletion,
+6
View File
@@ -43,6 +43,12 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
view.Set("form", settingsForm)
view.Set("readBehaviors", map[string]any{
"NoAutoMarkAsRead": form.NoAutoMarkAsRead,
"MarkAsReadOnView": form.MarkAsReadOnView,
"MarkAsReadOnViewButWaitForPlayerCompletion": form.MarkAsReadOnViewButWaitForPlayerCompletion,
"MarkAsReadOnlyOnPlayerCompletion": form.MarkAsReadOnlyOnPlayerCompletion,
})
view.Set("themes", model.Themes())
view.Set("languages", locale.AvailableLanguages)
view.Set("timezones", timezones)
+1
View File
@@ -24,6 +24,7 @@ func (h *handler) sharedEntries(w http.ResponseWriter, r *http.Request) {
builder := h.store.NewEntryQueryBuilder(user.ID)
builder.WithShareCodeNotEmpty()
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithSorting("id", user.EntryDirection)
builder.WithOffset(offset)
builder.WithLimit(user.EntriesPerPage)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"env": {
"browser": true,
"es2017": true
"es2020": true
},
"rules": {
"indent": ["error", 4]
+13 -1
View File
@@ -1,3 +1,15 @@
{
"esversion": 8
"bitwise": true,
"browser": true,
"eqeqeq": true,
"esversion": 11,
"freeze": true,
"latedef": "nofunc",
"noarg": true,
"nocomma": true,
"nonbsp": true,
"nonew": true,
"noreturnawait": true,
"shadow": true,
"varstmt": true
}
+8 -9
View File
@@ -71,12 +71,11 @@ function toggleMainMenu(event) {
const menu = document.querySelector(".header nav ul");
const menuToggleButton = document.querySelector(".logo");
if (menu.classList.contains("js-menu-show")) {
menu.classList.remove("js-menu-show");
menuToggleButton.setAttribute("aria-expanded", false);
} else {
menu.classList.add("js-menu-show");
menuToggleButton.setAttribute("aria-expanded", true);
}
menu.classList.toggle("js-menu-show");
}
// Handle click events for the main menu (<li> and <a>).
@@ -152,7 +151,7 @@ function handleEntryStatus(item, element, setToRead) {
const toasting = !element;
const currentEntry = findEntry(element);
if (currentEntry) {
if (!setToRead || currentEntry.querySelector(":is(a, button)[data-toggle-status]").dataset.value == "unread") {
if (!setToRead || currentEntry.querySelector(":is(a, button)[data-toggle-status]").dataset.value === "unread") {
toggleEntryStatus(currentEntry, toasting);
}
if (isListView() && currentEntry.classList.contains('current-item')) {
@@ -381,7 +380,7 @@ function openOriginalLink(openLinkInCurrentTab) {
const currentItem = document.querySelector(".current-item");
// If we are not on the list of starred items, move to the next item
if (document.location.href != document.querySelector(':is(a, button)[data-page=starred]').href) {
if (document.location.href !== document.querySelector(':is(a, button)[data-page=starred]').href) {
goToListItem(1);
}
markEntryAsRead(currentItem);
@@ -523,9 +522,9 @@ function goToListItem(offset) {
// By default adjust selection by offset
let itemOffset = (i + offset + items.length) % items.length;
// Allow jumping to top or bottom
if (offset == TOP) {
if (offset === TOP) {
itemOffset = 0;
} else if (offset == BOTTOM) {
} else if (offset === BOTTOM) {
itemOffset = items.length - 1;
}
const item = items[itemOffset];
@@ -596,7 +595,7 @@ function findEntry(element) {
}
function handleConfirmationMessage(linkElement, callback) {
if (linkElement.tagName != 'A' && linkElement.tagName != "BUTTON") {
if (linkElement.tagName !== 'A' && linkElement.tagName !== "BUTTON") {
linkElement = linkElement.parentNode;
}
@@ -662,7 +661,7 @@ function showToast(label, iconElement) {
const toastElementWrapper = document.getElementById("toast-wrapper");
if (toastElementWrapper) {
toastElementWrapper.classList.remove('toast-animate');
setTimeout(function () {
setTimeout(() => {
toastElementWrapper.classList.add('toast-animate');
}, 100);
}
@@ -782,7 +781,7 @@ function handleMediaControl(button) {
enclosures.forEach((enclosure) => {
switch (action) {
case "seek":
enclosure.currentTime = enclosure.currentTime + value > 0 ? enclosure.currentTime + value : 0;
enclosure.currentTime = Math.max(enclosure.currentTime + value, 0);
break;
case "speed":
// I set a floor speed of 0.25 to avoid too slow speed where it gives the impression it stopped.
+4 -4
View File
@@ -57,7 +57,7 @@ document.addEventListener("DOMContentLoaded", () => {
onClick("#webauthn-delete", () => { webauthnHandler.removeAllCredentials(); });
const registerButton = document.getElementById("webauthn-register");
if (registerButton != null) {
if (registerButton !== null) {
registerButton.disabled = false;
onClick("#webauthn-register", () => {
@@ -66,13 +66,13 @@ document.addEventListener("DOMContentLoaded", () => {
}
const loginButton = document.getElementById("webauthn-login");
if (loginButton != null) {
if (loginButton !== null) {
const abortController = new AbortController();
loginButton.disabled = false;
onClick("#webauthn-login", () => {
const usernameField = document.getElementById("form-username");
if (usernameField != null) {
if (usernameField !== null) {
abortController.abort();
webauthnHandler.login(usernameField.value).catch(err => WebAuthnHandler.showErrorMessage(err));
}
@@ -108,7 +108,7 @@ document.addEventListener("DOMContentLoaded", () => {
handleEntryStatus("next", event.target, true);
}, true);
onAuxClick("a[data-original-link='true']", (event) => {
if (event.button == 1) {
if (event.button === 1) {
handleEntryStatus("next", event.target, true);
}
}, true);
-19
View File
@@ -25,23 +25,4 @@ class DomHelper {
const elements = document.querySelectorAll(selector);
return [...elements].filter((element) => this.isVisible(element));
}
static hasPassiveEventListenerOption() {
var passiveSupported = false;
try {
var options = Object.defineProperty({}, "passive", {
get: function() {
passiveSupported = true;
}
});
window.addEventListener("test", options, options);
window.removeEventListener("test", options, options);
} catch(err) {
passiveSupported = false;
}
return passiveSupported;
}
}
+12 -19
View File
@@ -12,12 +12,12 @@ class KeyboardHandler {
listen() {
document.onkeydown = (event) => {
const key = this.getKey(event);
if (this.isEventIgnored(event, key) || this.isModifierKeyDown(event)) {
const key = KeyboardHandler.getKey(event);
if (this.isEventIgnored(event, key) || KeyboardHandler.isModifierKeyDown(event)) {
return;
}
if (key != "Enter") {
if (key !== "Enter") {
event.preventDefault();
}
@@ -51,25 +51,18 @@ class KeyboardHandler {
(this.queue.length < 1 && !this.triggers.has(key));
}
isModifierKeyDown(event) {
static isModifierKeyDown(event) {
return event.getModifierState("Control") || event.getModifierState("Alt") || event.getModifierState("Meta");
}
getKey(event) {
const mapping = {
'Esc': 'Escape',
'Up': 'ArrowUp',
'Down': 'ArrowDown',
'Left': 'ArrowLeft',
'Right': 'ArrowRight'
};
for (const key in mapping) {
if (mapping.hasOwnProperty(key) && key === event.key) {
return mapping[key];
}
static getKey(event) {
switch (event.key) {
case 'Esc': return 'Escape';
case 'Up': return 'ArrowUp';
case 'Down': return 'ArrowDown';
case 'Left': return 'ArrowLeft';
case 'Right': return 'ArrowRight';
default: return event.key;
}
return event.key;
}
}
+25 -31
View File
@@ -26,7 +26,7 @@ class TouchHandler {
return 0;
}
findElement(element) {
static findElement(element) {
if (element.classList.contains("entry-swipe")) {
return element;
}
@@ -42,7 +42,7 @@ class TouchHandler {
this.reset();
this.touch.start.x = event.touches[0].clientX;
this.touch.start.y = event.touches[0].clientY;
this.touch.element = this.findElement(event.touches[0].target);
this.touch.element = TouchHandler.findElement(event.touches[0].target);
this.touch.element.style.transitionDuration = "0s";
}
@@ -60,11 +60,7 @@ class TouchHandler {
if (absDistance > 0) {
this.touch.moved = true;
let tx = absDistance > 75 ? Math.pow(absDistance - 75, 0.5) + 75 : absDistance;
if (distance < 0) {
tx = -tx;
}
const tx = (absDistance > 75 ? Math.sqrt(absDistance - 75) + 75 : absDistance) * Math.sign(distance);
this.touch.element.style.transform = "translateX(" + tx + "px)";
@@ -78,9 +74,7 @@ class TouchHandler {
}
if (this.touch.element !== null) {
const absDistance = Math.abs(this.calculateDistance());
if (absDistance > 75) {
if (Math.abs(this.calculateDistance()) > 75) {
toggleEntryStatus(this.touch.element);
}
@@ -118,15 +112,15 @@ class TouchHandler {
return;
}
const distance = this.calculateDistance();
const absDistance = Math.abs(distance);
const now = Date.now();
if (now - this.touch.time <= 1000 && absDistance > 75) {
if (distance > 0) {
goToPage("previous");
} else {
goToPage("next");
if (Date.now() - this.touch.time <= 1000) {
const distance = this.calculateDistance();
const absDistance = Math.abs(distance);
if (absDistance > 75) {
if (distance > 0) {
goToPage("previous");
} else {
goToPage("next");
}
}
}
@@ -158,26 +152,26 @@ class TouchHandler {
}
listen() {
const hasPassiveOption = DomHelper.hasPassiveEventListenerOption();
const eventListenerOptions = { passive: true };
document.querySelectorAll(".entry-swipe").forEach((element) => {
element.addEventListener("touchstart", (e) => this.onItemTouchStart(e), hasPassiveOption ? { passive: true } : false);
element.addEventListener("touchmove", (e) => this.onItemTouchMove(e), hasPassiveOption ? { passive: false } : false);
element.addEventListener("touchend", (e) => this.onItemTouchEnd(e), hasPassiveOption ? { passive: true } : false);
element.addEventListener("touchcancel", () => this.reset(), hasPassiveOption ? { passive: true } : false);
element.addEventListener("touchstart", (e) => this.onItemTouchStart(e), eventListenerOptions);
element.addEventListener("touchmove", (e) => this.onItemTouchMove(e));
element.addEventListener("touchend", (e) => this.onItemTouchEnd(e), eventListenerOptions);
element.addEventListener("touchcancel", () => this.reset(), eventListenerOptions);
});
const element = document.querySelector(".entry-content");
if (element) {
if (element.classList.contains("gesture-nav-tap")) {
element.addEventListener("touchend", (e) => this.onTapEnd(e), hasPassiveOption ? { passive: true } : false);
element.addEventListener("touchmove", () => this.reset(), hasPassiveOption ? { passive: true } : false);
element.addEventListener("touchcancel", () => this.reset(), hasPassiveOption ? { passive: true } : false);
element.addEventListener("touchend", (e) => this.onTapEnd(e), eventListenerOptions);
element.addEventListener("touchmove", () => this.reset(), eventListenerOptions);
element.addEventListener("touchcancel", () => this.reset(), eventListenerOptions);
} else if (element.classList.contains("gesture-nav-swipe")) {
element.addEventListener("touchstart", (e) => this.onContentTouchStart(e), hasPassiveOption ? { passive: true } : false);
element.addEventListener("touchmove", (e) => this.onContentTouchMove(e), hasPassiveOption ? { passive: true } : false);
element.addEventListener("touchend", (e) => this.onContentTouchEnd(e), hasPassiveOption ? { passive: true } : false);
element.addEventListener("touchcancel", () => this.reset(), hasPassiveOption ? { passive: true } : false);
element.addEventListener("touchstart", (e) => this.onContentTouchStart(e), eventListenerOptions);
element.addEventListener("touchmove", (e) => this.onContentTouchMove(e), eventListenerOptions);
element.addEventListener("touchend", (e) => this.onContentTouchEnd(e), eventListenerOptions);
element.addEventListener("touchcancel", () => this.reset(), eventListenerOptions);
}
}
}
+1 -1
View File
@@ -149,7 +149,7 @@ class WebAuthnHandler {
}
catch (err) {
// Swallow aborted conditional logins
if (err instanceof DOMException && err.name == "AbortError") {
if (err instanceof DOMException && err.name === "AbortError") {
return;
}
WebAuthnHandler.showErrorMessage(err);
+1 -1
View File
@@ -139,7 +139,7 @@ func GenerateJavascriptBundles() error {
JavascriptBundles = make(map[string][]byte)
JavascriptBundleChecksums = make(map[string]string)
jsMinifier := js.Minifier{Version: 2017}
jsMinifier := js.Minifier{Version: 2020}
minifier := minify.New()
minifier.AddFunc("text/javascript", jsMinifier.Minify)
+1
View File
@@ -34,6 +34,7 @@ func (h *handler) showTagEntriesAllPage(w http.ResponseWriter, r *http.Request)
builder.WithTags([]string{tagName})
builder.WithSorting("status", "asc")
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithSorting("id", user.EntryDirection)
builder.WithOffset(offset)
builder.WithLimit(user.EntriesPerPage)
+1
View File
@@ -45,6 +45,7 @@ func (h *handler) showUnreadPage(w http.ResponseWriter, r *http.Request) {
builder = h.store.NewEntryQueryBuilder(user.ID)
builder.WithStatus(model.EntryStatusUnread)
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithSorting("id", user.EntryDirection)
builder.WithOffset(offset)
builder.WithLimit(user.EntriesPerPage)
builder.WithGloballyVisible()
+26
View File
@@ -6,6 +6,7 @@ package validator // import "miniflux.app/v2/internal/validator"
import (
"slices"
"strings"
"unicode"
"miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/model"
@@ -22,6 +23,10 @@ func ValidateUserCreationWithPassword(store *storage.Storage, request *model.Use
return locale.NewLocalizedError("error.user_already_exists")
}
if err := validateUsername(request.Username); err != nil {
return err
}
if err := validatePassword(request.Password); err != nil {
return err
}
@@ -146,6 +151,27 @@ func validatePassword(password string) *locale.LocalizedError {
return nil
}
// validateUsername return an error if the `username` argument contains
// a character that isn't alphanumerical nor `_` and `-`.
//
// Note: this validation should not be applied to previously created usernames,
// and cannot be applied to Google/OIDC accounts creation because the email
// address is used for the username field.
func validateUsername(username string) *locale.LocalizedError {
if strings.ContainsFunc(username, func(r rune) bool {
if unicode.IsLetter(r) || unicode.IsNumber(r) {
return false
}
if r == '_' || r == '-' || r == '@' || r == '.' {
return false
}
return true
}) {
return locale.NewLocalizedError("error.invalid_username")
}
return nil
}
func validateTheme(theme string) *locale.LocalizedError {
themes := model.Themes()
if _, found := themes[theme]; !found {
+27 -1
View File
@@ -3,7 +3,11 @@
package validator // import "miniflux.app/v2/internal/validator"
import "testing"
import (
"testing"
"miniflux.app/v2/internal/locale"
)
func TestIsValidURL(t *testing.T) {
scenarios := map[string]bool{
@@ -77,3 +81,25 @@ func TestIsValidDomain(t *testing.T) {
}
}
}
func TestValidateUsername(t *testing.T) {
scenarios := map[string]*locale.LocalizedError{
"jvoisin": nil,
"j.voisin": nil,
"j@vois.in": nil,
"invalid username": locale.NewLocalizedError("error.invalid_username"),
}
for username, expected := range scenarios {
result := validateUsername(username)
if expected == nil {
if result != nil {
t.Errorf(`got an unexpected error for %q instead of nil: %v`, username, result)
}
} else {
if result == nil {
t.Errorf(`expected an error, got nil.`)
}
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /go/src/app
RUN make miniflux
FROM rockylinux:9
RUN dnf install -y rpm-build systemd
RUN dnf install --setopt=install_weak_deps=False -y rpm-build systemd-rpm-macros
RUN mkdir -p /root/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
RUN echo "%_topdir /root/rpmbuild" >> .rpmmacros
COPY --from=build /go/src/app/miniflux /root/rpmbuild/SOURCES/miniflux
+1 -2
View File
@@ -16,8 +16,7 @@ BuildRoot: %{_topdir}/BUILD/%{name}-%{version}-%{release}
BuildArch: x86_64
Requires(pre): shadow-utils
%{?systemd_requires}
BuildRequires: systemd
%{?systemd_ordering}
AutoReqProv: no