Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33063a7775 | |||
| 49c62db2e1 | |||
| 400e8974f9 | |||
| 9c82e55b98 | |||
| c9c422b135 | |||
| 91f9a7650e | |||
| 605eeb4525 | |||
| eb6991ae49 | |||
| eac5d59f5b | |||
| 3861f1aa67 | |||
| 6af0cd5b5c | |||
| fccca0ce1e | |||
| 8c3a9184ac | |||
| 3b317b0b80 | |||
| a702bf0342 | |||
| e9520f5d1c | |||
| f5fde36d45 | |||
| 6cbe8c3a9d | |||
| 1e54a073d3 | |||
| bf66ef5a0f | |||
| 9b25ea4ed6 | |||
| f116f7dd6a | |||
| e540547104 | |||
| 5b1abbf340 | |||
| 0d72cef82e | |||
| 79ec6ef81f | |||
| 8d4954e29b | |||
| c81e17c20d | |||
| 5549f75dd7 | |||
| fc3c4873e5 | |||
| b87c547a07 | |||
| 3466e9e2d6 | |||
| 8df4b780a8 | |||
| e22520fc55 | |||
| 518bc4d6ff | |||
| 89620a7dd2 | |||
| bbfe39722a | |||
| f3989cdb2f | |||
| 195b75d185 | |||
| f52411f734 | |||
| b93543f416 | |||
| 057f760196 | |||
| 28fe053329 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/docker/**'
|
||||
jobs:
|
||||
docker-images:
|
||||
name: Docker Images
|
||||
|
||||
@@ -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: >
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
---------------------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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(¤tVersion)
|
||||
|
||||
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
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "Αποθήκευση...",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "सहेजा जा रहा है...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "保存中…",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "Сохранение…",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "Зберігаю...",
|
||||
|
||||
@@ -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": "保存中…",
|
||||
|
||||
@@ -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": "儲存中…",
|
||||
|
||||
@@ -104,6 +104,9 @@ type Integration struct {
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyIconURL string
|
||||
NtfyInternalLinks bool
|
||||
CuboxEnabled bool
|
||||
CuboxAPILink string
|
||||
DiscordEnabled bool
|
||||
DiscordWebhookLink string
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <tag>]]></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 != `'AT&T'` {
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2017": true
|
||||
"es2020": true
|
||||
},
|
||||
"rules": {
|
||||
"indent": ["error", 4]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Vendored
+4
-4
@@ -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);
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user