Merge branch 'main' of github.com:miniflux/v2 into patch-1

This commit is contained in:
mcnesium 2024-03-12 14:22:52 +01:00
commit d90738005f
No known key found for this signature in database
GPG Key ID: 7D6CC73E428F633F
163 changed files with 5990 additions and 1618 deletions

View File

@ -12,10 +12,13 @@ jobs:
- name: Set up Golang
uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.22.x"
check-latest: true
- name: Checkout
uses: actions/checkout@v4
- name: Compile binaries
env:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
uses: actions/upload-artifact@v4

View File

@ -29,6 +29,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
- name: Initialize CodeQL
uses: github/codeql-action/init@v3

View File

@ -1,6 +1,7 @@
name: Debian Packages
permissions: read-all
on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
@ -28,8 +29,34 @@ jobs:
run: make debian-packages
- name: List generated files
run: ls -l *.deb
build-packages-manually:
if: github.event_name != 'pull_request' && github.event_name != 'push'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
with:
install: true
- name: Available Docker Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Build Debian Packages
run: make debian-packages
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: packages
path: "*.deb"
if-no-files-found: error
retention-days: 3
publish-packages:
if: ${{ ! github.event.pull_request }}
if: github.event_name == 'push'
name: Publish Packages
runs-on: ubuntu-latest
steps:

View File

@ -13,11 +13,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install jshint
- name: Install linters
run: |
sudo npm install -g jshint@2.13.3
sudo npm install -g jshint@2.13.6 eslint@8.57.0
- name: Run jshint
run: jshint ui/static/js/*.js
run: jshint internal/ui/static/js/*.js
- name: Run ESLint
run: eslint internal/ui/static/js/*.js
golangci:
name: Golang Linter
@ -26,7 +28,12 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.22.x"
- run: "go vet ./..."
- uses: golangci/golangci-lint-action@v4
with:
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
- uses: dominikh/staticcheck-action@v1.3.0
with:
version: "2023.1.7"
install-go: false

View File

@ -1,6 +1,7 @@
name: RPM Packages
permissions: read-all
on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
@ -19,8 +20,25 @@ jobs:
run: make rpm
- name: List generated files
run: ls -l *.rpm
build-package-manually:
if: github.event_name != 'pull_request' && github.event_name != 'push'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: packages
path: "*.rpm"
if-no-files-found: error
retention-days: 3
publish-package:
if: ${{ ! github.event.pull_request }}
if: github.event_name == 'push'
name: Publish Packages
runs-on: ubuntu-latest
steps:

View File

@ -15,7 +15,7 @@ jobs:
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
go-version: ["1.22"]
go-version: ["1.22.x"]
steps:
- name: Set up Go
uses: actions/setup-go@v5
@ -43,7 +43,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.22.x"
- name: Checkout
uses: actions/checkout@v4
- name: Install Postgres client

113
ChangeLog
View File

@ -1,3 +1,116 @@
Version 2.1.1 (March 10, 2024)
-----------------------------
* Move search form to a dedicated page
* Add Readeck integration
* Add feed option to disable HTTP/2 to avoid fingerprinting
* Add `Enter` key as a hotkey to open selected item
* Proxify `video` element `poster` attribute
* Add a couple of new possible locations for feeds
* Hugo likes to generate `index.xml`
* `feed.atom` and `feed.rss` are used by enterprise-scale/old-school gigantic CMS
* Fix categories import from Thunderbird's OPML
* Fix logo misalignment when using languages that are more verbose than English
* Google Reader: Do not return a 500 error when no items is returned
* Handle RDF feeds with duplicated `<title>` elements
* Sort integrations alphabetically
* Add more URL validation in media proxy
* Add unit test to ensure each translation has the correct number of plurals
* Add missing plurals for some languages
* Makefile: quiet `git describe` and `rev-parse` stderr: When building from a tarball instead of a cloned git repo, there would be two `fatal: not a git repository` errors emitted even though the build succeeds. This is because of how `VERSION` and `COMMIT` are set in the Makefile. This PR suppresses the stderr for these variable assignments.
* Makefile: do not force `CGO_ENABLED=0` for `miniflux` target
* Add GitHub Action pipeline to build packages on-demand
* Remove Golint (deprecated), use `staticcheck` and `golangci-lint` instead
* Build amd64/arm64 Debian packages with CGO disabled
* Update `go.mod` and add `.exe` suffix to Windows binary
* Add a couple of fuzzers
* Fix CodeQL workflow
* Code and performance improvements:
* Use an `io.ReadSeeker` instead of an `io.Reader` to parse feeds
* Speed up the sanitizer:
- Allow Youtube URLs to start with `www`
- Use `strings.Builder` instead of a `bytes.Buffer`
- Use a `strings.NewReader` instead of a `bytes.NewBufferString`
- Sprinkles a couple of `continue` to make the code-flow more obvious
- Inline calls to `inList`, and put their parameters in the right order
- Simplify `isPixelTracker`
- Simplify `isValidIframeSource`, by extracting the hostname and comparing it directly, instead of using the full url and checking if it starts with multiple variations of the same one (`//`, `http:`, `https://` multiplied by `/www.`)
- Add a benchmark
- Instead of having to allocate a ~100 keys map containing possibly dynamic values (at least to the go compiler), allocate it once in a global variable. This significantly speeds things up, by reducing the garbage
- Use constant time access for maps instead of iterating on them
- Build a ~large whitelist map inline instead of constructing it item by item (and remove a duplicate key/value pair)
- Use `slices` instead of hand-rolled loops
collector/allocator involvements.
* Reuse a `Reader` instead of copying to a buffer when parsing an Atom feed
* Preallocate memory when exporting to OPML: This should marginally increase performance when exporting a large amount of feeds to OPML
* Delay call of `view.New` after logging the user in: There is no need to do extra work like creating a session and its associated view until the user has been properly identified and as many possibly-failing sql request have been successfully run
* Use constant-time comparison for anti-csrf tokens: This is probably completely overkill, but since anti-csrf tokens are secrets, they should be compared against untrusted inputs in constant time
* Simplify and optimize `genericProxyRewriter`
- Reduce the amount of nested loops: it's preferable to search the whole page once and filter on it (even with filters that should always be false), than searching it again for every element we're looking for.
- Factorize the proxying conditions into a `shouldProxy` function to reduce the copy-pasta.
* Speed up `removeUnlikelyCandidates`: `.Not` returns a brand new `Selection`, copied element by element
* Improve `EstimateReadingTime`'s speed by a factor 7
- Refactorise the tests and add some
- Use 250 signs instead of the whole text
- Only check for Korean, Chinese and Japanese script
- Add a benchmark
- Use a more idiomatic control flow
* Don't compute reading-time when unused: If the user doesn't display reading times, there is no need to compute them. This should speed things up a bit, since `whatlanggo.Detect` is abysmally slow.
* Simplify `username` generation for the integration tests: No need to generate random numbers 10 times, generate a single big-enough one. A single int64 should be more than enough
* Add missing regex anchor detected by CodeQL
* Don't mix up slices capacity and length
* Use prepared statements for intervals, `ArchiveEntries` and `updateEnclosures`
* Use modern for-loops introduced with Go 1.22
* Remove a superfluous condition: No need to check if the length of `line` is positive since we're checking afterwards that it contains the `=` sign
* Close resources as soon as possible, instead of using `defer()` in a loop
* Remove superfluous escaping in a regex
* Use `strings.ReplaceAll` instead of `strings.Replace(…, -1)`
* Use `strings.EqualFold` instead of `strings.ToLower(…) ==`
* Use `.WriteString(` instead of `.Write([]byte(…`
* Use `%q` instead of `"%s"`
* Make `internal/worker/worker.go` read-only
* Use a switch-case construct in `internal/locale/plural.go` instead of an avalanche of `if`
* Template functions: simplify `formatFileSize` and `duration` implementation
* Inline some templating functions
* Make use of `printer.Print` when possible
* Add a `printer.Print` to `internal/locale/printer.go`: No need to use variadic functions with string format interpolation to generate static strings
* Minor code simplification in `internal/ui/view/view.go`: No need to create the map item by item when we can create it in one go
* Build the map inline in `CountAllFeeds()`: No need to build an empty map to then add more fields in it one by one
* Miscellaneous improvements to `internal/reader/subscription/finder.go`:
- Surface `localizedError` in `FindSubscriptionsFromWellKnownURLs` via `slog`
- Use an inline declaration for new subscriptions, like done elsewhere in the
file, if only for consistency's sake
- Preallocate the `subscriptions` slice when using an RSS-bridge,
* Use an update-where for `MarkCategoryAsRead` instead of a subquery
* Simplify `CleanOldUserSessions`' query: No need for a subquery, filtering on `created_at` directly is enough
* Simplify `cleanupEntries`' query
- `NOT (hash=ANY(%4))` can be expressed as `hash NOT IN $4`
- There is no need for a subquery operating on the same table, moving the conditions out is equivalent.
* Reformat `ArchiveEntries`'s query for consistency's sake and replace the `=ANY` with an `IN`
* Reformat the query in `GetEntryIDs` and `GetReadTime`'s query for consistency's sake
* Simplify `WeeklyFeedEntryCount`: No need for a `BETWEEN`: we want to filter on entries published in the last week, no need to express is as "entries published between now and last week", "entries published after last week" is enough
* Add some tests for `add_image_title`
* Remove `github.com/google/uuid` dependencies: Replace it with a hand-rolled implementation. Heck, an UUID isn't even a requirement according to Omnivore API docs
* Simplify `internal/reader/icon/finder.go`:
- Use a simple regex to parse data uri instead of a hand-rolled parser, and document what fields are considered mandatory.
- Use case-insensitive matching to find (fav)icons, instead of doing the same query twice with different letter cases
- Add `apple-touch-icon-precomposed.png` as a fallback `favicon`
- Reorder the queries to have `icon` first, since it seems to be the most popular one. It used to be last, meaning that pages had to be parsed completely 4 times, instead of one now.
- Minor factorisation in `findIconURLsFromHTMLDocument`
* Small refactoring of `internal/reader/date/parser.go`:
- Split dates formats into those that require local times and those who don't, so that there is no need to have a switch-case in the for loop with around 250 iterations at most.
- Be more strict when it comes to timezones, previously invalid ones like -13 were accepted. Also add a test for this.
- Bail out early if the date is an empty string.
* Make use of Go ≥ 1.21 slices package instead of hand-rolled loops
* Reorder the fields of the `Entry` struct to save some memory
* Dependencies update:
* Bump `golang.org/x/oauth2` from `0.17.0` to `0.18.0`
* Bump `github.com/prometheus/client_golang` from `1.18.0` to `1.19.0`
* Bump `github.com/tdewolff/minify/v2` from `2.20.16` to `2.20.18`
* Bump `github.com/PuerkitoBio/goquery` from `1.8.1` to `1.9.1`
* Bump `golang.org/x/crypto` from `0.19.0` to `0.20.0`
* Bump `github.com/go-jose/go-jose/v3` from `3.0.1` to `3.0.3`
Version 2.1.0 (February 17, 2024)
---------------------------------

View File

@ -1,7 +1,7 @@
APP := miniflux
DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --abbrev=0)
COMMIT := $(shell git rev-parse --short HEAD)
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
BUILD_DATE := `date +%FT%T%z`
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
@ -44,7 +44,7 @@ export PGPASSWORD := postgres
debian-packages
miniflux:
@ CGO_ENABLED=0 go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
miniflux-no-pie:
@ go build -ldflags=$(LD_FLAGS) -o $(APP) main.go
@ -77,7 +77,7 @@ openbsd-amd64:
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
windows-amd64:
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
@ -98,19 +98,21 @@ openbsd-x86:
@ GOOS=openbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
windows-x86:
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
run:
@ LOG_DATE_TIME=1 DEBUG=1 RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
clean:
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe
test:
go test -cover -race -count=1 ./...
lint:
golint -set_exit_status ${PKG_LIST}
go vet ./...
staticcheck ./...
golangci-lint run --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
integration-test:
psql -U postgres -c 'drop database if exists miniflux_test;'
@ -124,7 +126,7 @@ integration-test:
RUN_MIGRATIONS=1 \
DEBUG=1 \
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
go test -v -tags=integration -count=1 miniflux.app/v2/internal/tests

View File

@ -107,7 +107,7 @@ type Subscription struct {
}
func (s Subscription) String() string {
return fmt.Sprintf(`Title="%s", URL="%s", Type="%s"`, s.Title, s.URL, s.Type)
return fmt.Sprintf(`Title=%q, URL=%q, Type=%q`, s.Title, s.URL, s.Type)
}
// Subscriptions represents a list of subscriptions.
@ -140,6 +140,7 @@ type Feed struct {
Password string `json:"password"`
Category *Category `json:"category,omitempty"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
}
// FeedCreationRequest represents the request to create a feed.
@ -160,6 +161,7 @@ type FeedCreationRequest struct {
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
}
// FeedModificationRequest represents the request to update a feed.
@ -182,6 +184,7 @@ type FeedModificationRequest struct {
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
HideGlobally *bool `json:"hide_globally"`
DisableHTTP2 *bool `json:"disable_http2"`
}
// FeedIcon represents the feed icon.
@ -202,24 +205,24 @@ type Feeds []*Feed
// Entry represents a subscription item in the system.
type Entry struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Status string `json:"status"`
Date time.Time `json:"published_at"`
ChangedAt time.Time `json:"changed_at"`
CreatedAt time.Time `json:"created_at"`
Feed *Feed `json:"feed,omitempty"`
Hash string `json:"hash"`
Title string `json:"title"`
URL string `json:"url"`
CommentsURL string `json:"comments_url"`
Date time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
ChangedAt time.Time `json:"changed_at"`
Title string `json:"title"`
Status string `json:"status"`
Content string `json:"content"`
Author string `json:"author"`
ShareCode string `json:"share_code"`
Starred bool `json:"starred"`
ReadingTime int `json:"reading_time"`
Enclosures Enclosures `json:"enclosures,omitempty"`
Feed *Feed `json:"feed,omitempty"`
Tags []string `json:"tags"`
ReadingTime int `json:"reading_time"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Starred bool `json:"starred"`
}
// EntryModificationRequest represents a request to modify an entry.

29
go.mod
View File

@ -1,22 +1,21 @@
module miniflux.app/v2
// +heroku goVersion go1.21
// +heroku goVersion go1.22
require (
github.com/PuerkitoBio/goquery v1.8.1
github.com/PuerkitoBio/goquery v1.9.1
github.com/abadojack/whatlanggo v1.0.1
github.com/coreos/go-oidc/v3 v3.9.0
github.com/go-webauthn/webauthn v0.10.1
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.18.0
github.com/tdewolff/minify/v2 v2.20.17
github.com/prometheus/client_golang v1.19.0
github.com/tdewolff/minify/v2 v2.20.18
github.com/yuin/goldmark v1.7.0
golang.org/x/crypto v0.19.0
golang.org/x/net v0.21.0
golang.org/x/oauth2 v0.17.0
golang.org/x/term v0.17.0
golang.org/x/crypto v0.21.0
golang.org/x/net v0.22.0
golang.org/x/oauth2 v0.18.0
golang.org/x/term v0.18.0
mvdan.cc/xurls/v2 v2.5.0
)
@ -31,19 +30,19 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/fxamacker/cbor/v2 v2.5.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/tdewolff/parse/v2 v2.7.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.31.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
)
go 1.21
go 1.22

60
go.sum
View File

@ -1,8 +1,7 @@
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@ -16,8 +15,8 @@ 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.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-webauthn/webauthn v0.10.1 h1:+RFKj4yHPy282teiiy5sqTYPfRilzBpJyedrz9KsNFE=
github.com/go-webauthn/webauthn v0.10.1/go.mod h1:a7BwAtrSMkeuJXtIKz433Av99nAv01pdfzB0a9xkDnI=
github.com/go-webauthn/x v0.1.8 h1:f1C6k1AyUlDvnIzWSW+G9rN9nbp1hhLXZagUtyxZ8nc=
@ -28,10 +27,10 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -40,26 +39,24 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tdewolff/minify/v2 v2.20.17 h1:zGqEDhspr3XjSrQI/56vw9IdAhLAaKTLXWnDBsxNVt8=
github.com/tdewolff/minify/v2 v2.20.17/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM=
github.com/tdewolff/minify/v2 v2.20.18 h1:y+s6OzlZwFqApgNXWNtaMuEMEPbHT72zrCyb9Az35Xo=
github.com/tdewolff/minify/v2 v2.20.18/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM=
github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ=
github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
@ -71,47 +68,46 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
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/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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.7.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.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@ -128,8 +124,8 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -275,7 +275,9 @@ func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
}
entryUpdateRequest.Patch(entry)
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
json.ServerError(w, r, err)

View File

@ -42,6 +42,7 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
requestBuilder.UseProxy(subscriptionDiscoveryRequest.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
subscriptionDiscoveryRequest.URL,

View File

@ -77,7 +77,7 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
}
if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {
json.BadRequest(w, r, errors.New("Only administrators can change permissions of standard users"))
json.BadRequest(w, r, errors.New("only administrators can change permissions of standard users"))
return
}
}
@ -141,7 +141,7 @@ func (h *handler) userByID(w http.ResponseWriter, r *http.Request) {
userID := request.RouteInt64Param(r, "userID")
user, err := h.store.UserByID(userID)
if err != nil {
json.BadRequest(w, r, errors.New("Unable to fetch this user from the database"))
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
return
}
@ -163,7 +163,7 @@ func (h *handler) userByUsername(w http.ResponseWriter, r *http.Request) {
username := request.RouteStringParam(r, "username")
user, err := h.store.UserByUsername(username)
if err != nil {
json.BadRequest(w, r, errors.New("Unable to fetch this user from the database"))
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
return
}
@ -194,7 +194,7 @@ func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
}
if user.ID == request.UserID(r) {
json.BadRequest(w, r, errors.New("You cannot remove yourself"))
json.BadRequest(w, r, errors.New("you cannot remove yourself"))
return
}

View File

@ -45,7 +45,7 @@ func refreshFeeds(store *storage.Storage) {
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
)
for i := 0; i < config.Opts.WorkerPoolSize(); i++ {
for i := range config.Opts.WorkerPoolSize() {
wg.Add(1)
go func(workerID int) {
defer wg.Done()

View File

@ -1944,7 +1944,7 @@ func TestParseConfigDumpOutput(t *testing.T) {
t.Fatal(err)
}
if _, err := tmpfile.Write([]byte(serialized)); err != nil {
if _, err := tmpfile.WriteString(serialized); err != nil {
t.Fatal(err)
}

View File

@ -4,12 +4,12 @@
package config // import "miniflux.app/v2/internal/config"
import (
"crypto/rand"
"fmt"
"sort"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/version"
)
@ -171,9 +171,6 @@ type Options struct {
// NewOptions returns Options with default values.
func NewOptions() *Options {
randomKey := make([]byte, 16)
rand.Read(randomKey)
return &Options{
HTTPS: defaultHTTPS,
logFile: defaultLogFile,
@ -242,7 +239,7 @@ func NewOptions() *Options {
metricsPassword: defaultMetricsPassword,
watchdog: defaultWatchdog,
invidiousInstance: defaultInvidiousInstance,
proxyPrivateKey: randomKey,
proxyPrivateKey: crypto.GenerateRandomBytes(16),
webAuthn: defaultWebAuthn,
}
}

View File

@ -56,7 +56,7 @@ func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
lines = append(lines, line)
}
}

View File

@ -7,6 +7,7 @@ import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
@ -16,8 +17,7 @@ import (
// HashFromBytes returns a SHA-256 checksum of the input.
func HashFromBytes(value []byte) string {
sum := sha256.Sum256(value)
return fmt.Sprintf("%x", sum)
return fmt.Sprintf("%x", sha256.Sum256(value))
}
// Hash returns a SHA-256 checksum of a string.
@ -55,3 +55,12 @@ func GenerateSHA256Hmac(secret string, data []byte) string {
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}
func GenerateUUID() string {
b := GenerateRandomBytes(16)
return fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
func ConstantTimeCmp(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

View File

@ -855,4 +855,20 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN readeck_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN readeck_only_url bool default 'f';
ALTER TABLE integrations ADD COLUMN readeck_url text default '';
ALTER TABLE integrations ADD COLUMN readeck_api_key text default '';
ALTER TABLE integrations ADD COLUMN readeck_labels text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disable_http2 bool default 'f'`
_, err = tx.Exec(sql)
return err
},
}

View File

@ -909,7 +909,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
)
if err := checkOutputFormat(w, r); err != nil {
json.ServerError(w, r, err)
json.BadRequest(w, r, err)
return
}
@ -960,7 +960,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
}
if len(entries) == 0 {
json.ServerError(w, r, fmt.Errorf("googlereader: no items returned from the database"))
json.BadRequest(w, r, fmt.Errorf("googlereader: no items returned from the database for item IDs: %v", itemIDs))
return
}
@ -984,7 +984,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
}
contentItems := make([]contentItem, len(entries))
for i, entry := range entries {
enclosures := make([]contentItemEnclosure, len(entry.Enclosures))
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
for _, enclosure := range entry.Enclosures {
enclosures = append(enclosures, contentItemEnclosure{URL: enclosure.URL, Type: enclosure.MimeType})
}
@ -1206,7 +1206,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
)
if err := checkOutputFormat(w, r); err != nil {
json.ServerError(w, r, err)
json.BadRequest(w, r, err)
return
}
@ -1252,7 +1252,7 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
)
if err := checkOutputFormat(w, r); err != nil {
json.ServerError(w, r, err)
json.BadRequest(w, r, err)
return
}
@ -1277,7 +1277,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
)
if err := checkOutputFormat(w, r); err != nil {
json.ServerError(w, r, fmt.Errorf("googlereader: output only as json supported"))
json.BadRequest(w, r, err)
return
}

View File

@ -19,6 +19,7 @@ import (
"miniflux.app/v2/internal/integration/omnivore"
"miniflux.app/v2/internal/integration/pinboard"
"miniflux.app/v2/internal/integration/pocket"
"miniflux.app/v2/internal/integration/readeck"
"miniflux.app/v2/internal/integration/readwise"
"miniflux.app/v2/internal/integration/shaarli"
"miniflux.app/v2/internal/integration/shiori"
@ -250,6 +251,29 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.ReadeckEnabled {
slog.Debug("Sending entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := readeck.NewClient(
userIntegrations.ReadeckURL,
userIntegrations.ReadeckAPIKey,
userIntegrations.ReadeckLabels,
userIntegrations.ReadeckOnlyURL,
)
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.ReadwiseEnabled {
slog.Debug("Sending entry to Readwise",
slog.Int64("user_id", userIntegrations.UserID),

View File

@ -28,7 +28,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixU
for _, entry := range entries {
textMessages = append(textMessages, fmt.Sprintf(`[%s] %s - %s`, feed.Title, entry.Title, entry.URL))
formattedTextMessages = append(formattedTextMessages, fmt.Sprintf(`<li><strong>%s</strong>: <a href="%s">%s</a></li>`, feed.Title, entry.URL, entry.Title))
formattedTextMessages = append(formattedTextMessages, fmt.Sprintf(`<li><strong>%s</strong>: <a href=%q>%s</a></li>`, feed.Title, entry.URL, entry.Title))
}
_, err = client.SendFormattedTextMessage(

View File

@ -11,8 +11,7 @@ import (
"net/http"
"time"
"github.com/google/uuid"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/version"
)
@ -79,7 +78,7 @@ func (c *client) SaveUrl(url string) error {
"query": mutation,
"variables": map[string]interface{}{
"input": map[string]interface{}{
"clientRequestId": uuid.New().String(),
"clientRequestId": crypto.GenerateUUID(),
"source": "api",
"url": url,
},

View File

@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package readeck // import "miniflux.app/v2/internal/integration/readeck"
import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
labels string
onlyURL bool
}
func NewClient(baseURL, apiKey, labels string, onlyURL bool) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, labels: labels, onlyURL: onlyURL}
}
func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("readeck: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/bookmarks/")
if err != nil {
return fmt.Errorf(`readeck: invalid API endpoint: %v`, err)
}
labelsSplitFn := func(c rune) bool {
return c == ',' || c == ' '
}
labelsSplit := strings.FieldsFunc(c.labels, labelsSplitFn)
var request *http.Request
if c.onlyURL {
requestBodyJson, err := json.Marshal(&readeckBookmark{
Url: entryURL,
Title: entryTitle,
Labels: labelsSplit,
})
if err != nil {
return fmt.Errorf("readeck: unable to encode request body: %v", err)
}
request, err = http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBodyJson))
if err != nil {
return fmt.Errorf("readeck: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
} else {
requestBody := new(bytes.Buffer)
multipartWriter := multipart.NewWriter(requestBody)
urlPart, err := multipartWriter.CreateFormField("url")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry url): %v", err)
}
urlPart.Write([]byte(entryURL))
titlePart, err := multipartWriter.CreateFormField("title")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry title): %v", err)
}
titlePart.Write([]byte(entryTitle))
featurePart, err := multipartWriter.CreateFormField("feature_find_main")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (feature_find_main flag): %v", err)
}
featurePart.Write([]byte("false")) // false to disable readability
for _, label := range labelsSplit {
labelPart, err := multipartWriter.CreateFormField("labels")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry labels): %v", err)
}
labelPart.Write([]byte(label))
}
contentBodyHeader, err := json.Marshal(&partContentHeader{
Url: entryURL,
ContentHeader: contentHeader{ContentType: "text/html"},
})
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry content header): %v", err)
}
contentPart, err := multipartWriter.CreateFormFile("resource", "blob")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry content): %v", err)
}
contentPart.Write(contentBodyHeader)
contentPart.Write([]byte("\n"))
contentPart.Write([]byte(entryContent))
err = multipartWriter.Close()
if err != nil {
return fmt.Errorf("readeck: unable to encode request body: %v", err)
}
request, err = http.NewRequest(http.MethodPost, apiEndpoint, requestBody)
if err != nil {
return fmt.Errorf("readeck: unable to create request: %v", err)
}
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
}
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("readeck: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("readeck: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type readeckBookmark struct {
Url string `json:"url"`
Title string `json:"title"`
Labels []string `json:"labels,omitempty"`
}
type contentHeader struct {
ContentType string `json:"content-type"`
}
type partContentHeader struct {
Url string `json:"url"`
ContentHeader contentHeader `json:"headers"`
}

View File

@ -20,7 +20,7 @@ var translationFiles embed.FS
// LoadCatalogMessages loads and parses all translations encoded in JSON.
func LoadCatalogMessages() error {
var err error
defaultCatalog = make(catalog)
defaultCatalog = make(catalog, len(AvailableLanguages()))
for language := range AvailableLanguages() {
defaultCatalog[language], err = loadTranslationFile(language)

View File

@ -53,11 +53,11 @@ func TestAllKeysHaveValue(t *testing.T) {
switch value := v.(type) {
case string:
if value == "" {
t.Errorf(`The key %q for the language %q have an empty string as value`, k, language)
t.Errorf(`The key %q for the language %q has an empty string as value`, k, language)
}
case []string:
case []any:
if len(value) == 0 {
t.Errorf(`The key %q for the language %q have an empty list as value`, k, language)
t.Errorf(`The key %q for the language %q has an empty list as value`, k, language)
}
}
}
@ -88,3 +88,20 @@ func TestMissingTranslations(t *testing.T) {
}
}
}
func TestTranslationFilePluralForms(t *testing.T) {
for language := range AvailableLanguages() {
messages, err := loadTranslationFile(language)
if err != nil {
t.Fatalf(`Unable to load translation messages for language %q`, language)
}
for k, v := range messages {
if value, ok := v.([]any); ok {
if len(value) != numberOfPluralFormsPerLanguage[language] {
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(value), numberOfPluralFormsPerLanguage[language])
}
}
}
}
}

View File

@ -3,6 +3,27 @@
package locale // import "miniflux.app/v2/internal/locale"
var numberOfPluralFormsPerLanguage = map[string]int{
"en_US": 2,
"es_ES": 2,
"fr_FR": 2,
"de_DE": 2,
"pl_PL": 3,
"pt_BR": 2,
"zh_CN": 1,
"zh_TW": 1,
"nl_NL": 2,
"ru_RU": 3,
"it_IT": 2,
"ja_JP": 1,
"tr_TR": 2,
"el_EL": 2,
"fi_FI": 2,
"hi_IN": 2,
"uk_UA": 3,
"id_ID": 1,
}
// AvailableLanguages returns the list of available languages.
func AvailableLanguages() map[string]string {
return map[string]string{

View File

@ -3,69 +3,65 @@
package locale // import "miniflux.app/v2/internal/locale"
type pluralFormFunc func(n int) int
// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
var pluralForms = map[string]pluralFormFunc{
var pluralForms = map[string](func(n int) int){
// nplurals=2; plural=(n != 1);
"default": func(n int) int {
if n != 1 {
return 1
}
return 0
},
// nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);
"ar_AR": func(n int) int {
if n == 0 {
switch {
case n == 0:
return 0
}
if n == 1 {
case n == 1:
return 1
}
if n == 2 {
case n == 2:
return 2
}
if n%100 >= 3 && n%100 <= 10 {
case n%100 >= 3 && n%100 <= 10:
return 3
}
if n%100 >= 11 {
case n%100 >= 11:
return 4
}
return 5
},
// nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
"cs_CZ": func(n int) int {
if n == 1 {
switch {
case n == 1:
return 0
}
if n >= 2 && n <= 4 {
case n >= 2 && n <= 4:
return 1
}
return 2
},
// nplurals=2; plural=(n > 1);
"fr_FR": func(n int) int {
if n > 1 {
return 1
}
return 0
},
// nplurals=1; plural=0;
"id_ID": func(n int) int {
return 0
},
// nplurals=1; plural=0;
"ja_JP": func(n int) int {
return 0
},
// nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
"pl_PL": func(n int) int {
if n == 1 {
switch {
case n == 1:
return 0
}
if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
return 1
}
return 2
},
// nplurals=2; plural=(n > 1);
@ -76,23 +72,31 @@ var pluralForms = map[string]pluralFormFunc{
return 0
},
"ru_RU": pluralFormRuSrUa,
// nplurals=2; plural=(n > 1);
"tr_TR": func(n int) int {
if n > 1 {
return 1
}
return 0
},
"uk_UA": pluralFormRuSrUa,
"sr_RS": pluralFormRuSrUa,
// nplurals=1; plural=0;
"zh_CN": func(n int) int {
return 0
},
"zh_TW": func(n int) int {
return 0
},
}
// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
func pluralFormRuSrUa(n int) int {
if n%10 == 1 && n%100 != 11 {
switch {
case n%10 == 1 && n%100 != 11:
return 0
}
if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
return 1
}
return 2
}

View File

@ -25,6 +25,20 @@ func TestPluralRules(t *testing.T) {
2: 1,
5: 2,
},
"fr_FR": {
1: 0,
2: 1,
5: 1,
},
"id_ID": {
1: 0,
5: 0,
},
"ja_JP": {
1: 0,
2: 0,
5: 0,
},
"pl_PL": {
1: 0,
2: 1,
@ -45,10 +59,24 @@ func TestPluralRules(t *testing.T) {
2: 1,
5: 2,
},
"tr_TR": {
1: 0,
2: 1,
5: 1,
},
"uk_UA": {
1: 0,
2: 1,
5: 2,
},
"zh_CN": {
1: 0,
5: 0,
},
"zh_TW": {
1: 0,
5: 0,
},
}
for rule, values := range scenarios {

View File

@ -10,6 +10,15 @@ type Printer struct {
language string
}
func (p *Printer) Print(key string) string {
if str, ok := defaultCatalog[p.language][key]; ok {
if translation, ok := str.(string); ok {
return translation
}
}
return key
}
// Printf is like fmt.Printf, but using language-specific formatting.
func (p *Printer) Printf(key string, args ...interface{}) string {
var translation string

View File

@ -35,6 +35,7 @@
"menu.about": "Über",
"menu.export": "Exportieren",
"menu.import": "Importieren",
"menu.search": "Suche",
"menu.create_category": "Kategorie anlegen",
"menu.mark_page_as_read": "Diese Seite als gelesen markieren",
"menu.mark_all_as_read": "Alle als gelesen markieren",
@ -327,6 +328,7 @@
"form.feed.label.urlrewrite_rules": "Umschreibregeln für URL",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Über Proxy abrufen",
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
@ -447,6 +449,11 @@
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
"form.integration.readeck_activate": "Artikel in Readeck speichern",
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.shiori_activate": "Artikel in Shiori speichern",
"form.integration.shiori_endpoint": "Shiori API-Endpunkt",
"form.integration.shiori_username": "Shiori Benutzername",

View File

@ -35,6 +35,7 @@
"menu.about": "Περί",
"menu.export": "Εξαγωγή",
"menu.import": "Εισαγωγή",
"menu.search": "Αναζήτηση",
"menu.create_category": "Δημιουργήστε μια κατηγορία",
"menu.mark_page_as_read": "Σημείωση αυτής της σελίδας ως αναγνωσμένη",
"menu.mark_all_as_read": "Σημείωση όλων ως αναγνωσμένα",
@ -327,6 +328,7 @@
"form.feed.label.keeplist_rules": "Κρατήστε Κανόνες",
"form.feed.label.ignore_http_cache": "Αγνοήστε την προσωρινή μνήμη HTTP",
"form.feed.label.allow_self_signed_certificates": "Να επιτρέπονται αυτο-υπογεγραμμένα ή μη έγκυρα πιστοποιητικά",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Λήψη μέσω διακομιστή μεσολάβησης",
"form.feed.label.disabled": "Μη ανανέωση αυτής της ροής",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -447,6 +449,11 @@
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
"form.integration.readeck_api_key": "Κλειδί API Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
"form.integration.shiori_activate": "Αποθήκευση άρθρων στο Shiori",
"form.integration.shiori_endpoint": "Τελικό σημείο Shiori",
"form.integration.shiori_username": "Όνομα Χρήστη Shiori",

View File

@ -35,6 +35,7 @@
"menu.about": "About",
"menu.export": "Export",
"menu.import": "Import",
"menu.search": "Search",
"menu.create_category": "Create a category",
"menu.mark_page_as_read": "Mark this page as read",
"menu.mark_all_as_read": "Mark all as read",
@ -327,6 +328,7 @@
"form.feed.label.urlrewrite_rules": "URL Rewrite Rules",
"form.feed.label.ignore_http_cache": "Ignore HTTP cache",
"form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Fetch via proxy",
"form.feed.label.disabled": "Do not refresh this feed",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -447,6 +449,11 @@
"form.integration.matrix_bot_password": "Password for Matrix user",
"form.integration.matrix_bot_url": "Matrix server URL",
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
"form.integration.readeck_activate": "Save entries to readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Readeck API key",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Send only URL (instead of full content)",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",

View File

@ -35,6 +35,7 @@
"menu.about": "Acerca de",
"menu.export": "Exportar",
"menu.import": "Importar",
"menu.search": "Buscar",
"menu.create_category": "Crear una categoría",
"menu.mark_page_as_read": "Marcar esta página como leída",
"menu.mark_all_as_read": "Marcar todos como leídos",
@ -327,6 +328,7 @@
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
"form.feed.label.disabled": "No actualice este feed",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -447,6 +449,11 @@
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
"form.integration.matrix_bot_url": "URL del servidor de Matrix",
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
"form.integration.readeck_activate": "Enviar artículos a Readeck",
"form.integration.readeck_endpoint": "Acceso API de Readeck",
"form.integration.readeck_api_key": "Clave de API de Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
"form.integration.shiori_activate": "Guardar artículos a Shiori",
"form.integration.shiori_endpoint": "Extremo de API de Shiori",
"form.integration.shiori_username": "Nombre de usuario de Shiori",

View File

@ -35,6 +35,7 @@
"menu.about": "Tietoja",
"menu.export": "Vie",
"menu.import": "Tuo",
"menu.search": "Haku",
"menu.create_category": "Luo kategoria",
"menu.mark_page_as_read": "Merkitse tämä sivu luetuksi",
"menu.mark_all_as_read": "Merkitse kaikki luetuksi",
@ -327,6 +328,7 @@
"form.feed.label.keeplist_rules": "Keep-säännöt",
"form.feed.label.ignore_http_cache": "Ohita HTTP-välimuisti",
"form.feed.label.allow_self_signed_certificates": "Salli itseallekirjoitetut tai virheelliset varmenteet",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Nouda välityspalvelimen kautta",
"form.feed.label.disabled": "Älä päivitä tätä syötettä",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -447,6 +449,11 @@
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
"form.integration.readeck_api_key": "Readeck API-avain",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",

View File

@ -35,6 +35,7 @@
"menu.about": "À propos",
"menu.export": "Export",
"menu.import": "Import",
"menu.search": "Recherche",
"menu.create_category": "Créer une catégorie",
"menu.mark_page_as_read": "Marquer cette page comme lu",
"menu.mark_all_as_read": "Tout marquer comme lu",
@ -327,6 +328,7 @@
"form.feed.label.urlrewrite_rules": "Règles de réécriture d'URL",
"form.feed.label.ignore_http_cache": "Ignorer le cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Autoriser les certificats auto-signés ou non valides",
"form.feed.label.disable_http2": "Désactiver HTTP/2",
"form.feed.label.fetch_via_proxy": "Récupérer via proxy",
"form.feed.label.disabled": "Ne pas actualiser ce flux",
"form.feed.label.no_media_player": "Pas de lecteur multimedia (audio/vidéo)",
@ -447,6 +449,11 @@
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
"form.integration.matrix_bot_url": "URL du serveur Matrix",
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
"form.integration.readeck_api_key": "Clé d'API de Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
"form.integration.shiori_activate": "Sauvegarder les articles vers Shiori",
"form.integration.shiori_endpoint": "URL de l'API de Shiori",
"form.integration.shiori_username": "Nom d'utilisateur de Shiori",

View File

@ -35,6 +35,7 @@
"menu.about": "के बारे में",
"menu.export": "निर्यात करे",
"menu.import": "आयात करे",
"menu.search": "खोज",
"menu.create_category": "श्रेणी बनाए",
"menu.mark_page_as_read": "इस पृष्ठ को पढ़ा हुआ चिह्नित करें",
"menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें",
@ -327,6 +328,7 @@
"form.feed.label.urlrewrite_rules": " यूआरएल पुनर्लेखन नियम",
"form.feed.label.ignore_http_cache": "एचटीटीपी कैश पर ध्यान न दें",
"form.feed.label.allow_self_signed_certificates": "स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "प्रॉक्सी के माध्यम से प्राप्त करें",
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -447,6 +449,11 @@
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
@ -518,4 +525,4 @@
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
}

View File

@ -35,6 +35,7 @@
"menu.about": "Tentang",
"menu.export": "Ekspor",
"menu.import": "Impor",
"menu.search": "Cari",
"menu.create_category": "Buat kategori",
"menu.mark_page_as_read": "Tandai halaman ini sebagai telah dibaca",
"menu.mark_all_as_read": "Tandai semua sebagai telah dibaca",
@ -83,38 +84,33 @@
"entry.shared_entry.title": "Buka tautan publik",
"entry.shared_entry.label": "Bagikan",
"entry.estimated_reading_time": [
"%d menit untuk dibaca"
"%d menit untuk dibaca"
],
"entry.tags.label": "Tanda:",
"page.shared_entries.title": "Entri yang Dibagikan",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
"%d shared entry"
],
"page.unread.title": "Belum Dibaca",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
"%d unread entry"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
"%d entry in total"
],
"page.starred.title": "Markah",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
"%d starred entry"
],
"page.categories.title": "Kategori",
"page.categories.no_feed": "Tidak ada umpan.",
"page.categories.entries": "Artikel",
"page.categories.feeds": "Langganan",
"page.categories.feed_count": [
"Ada %d umpan."
"Ada %d umpan."
],
"page.categories_count": [
"%d category",
"%d categories"
"%d category"
],
"page.new_category.title": "Kategori Baru",
"page.new_user.title": "Pengguna Baru",
@ -126,12 +122,11 @@
"page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "Jumlah entri yang telah dibaca",
"page.feeds.error_count": [
"%d galat"
"%d galat"
],
"page.history.title": "Riwayat",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
"%d read entry"
],
"page.import.title": "Impor",
"page.search.title": "Hasil Pencarian",
@ -212,8 +207,7 @@
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.settings.webauthn.delete": [
"Remove %d passkey",
"Remove %d passkeys"
"Remove %d passkey"
],
"page.login.title": "Masuk",
"page.login.google_signin": "Masuk dengan Google",
@ -324,6 +318,7 @@
"form.feed.label.urlrewrite_rules": "Aturan Tulis Ulang URL",
"form.feed.label.ignore_http_cache": "Abaikan Tembolok HTTP",
"form.feed.label.allow_self_signed_certificates": "Perbolehkan sertifikat web tidak valid atau sertifikasi sendiri",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Ambil via Proksi",
"form.feed.label.disabled": "Jangan perbarui umpan ini",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -444,6 +439,11 @@
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
"form.integration.matrix_bot_url": "URL Peladen Matrix",
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
"form.integration.readeck_endpoint": "Titik URL API Readeck",
"form.integration.readeck_api_key": "Kunci API Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Kirim hanya URL (alih-alih konten penuh)",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
@ -463,26 +463,25 @@
"time_elapsed.yesterday": "kemarin",
"time_elapsed.now": "baru saja",
"time_elapsed.minutes": [
"%d menit yang lalu"
"%d menit yang lalu"
],
"time_elapsed.hours": [
"%d jam yang lalu"
"%d jam yang lalu"
],
"time_elapsed.days": [
"%d hari yang lalu"
"%d hari yang lalu"
],
"time_elapsed.weeks": [
"%d pekan yang lalu"
"%d pekan yang lalu"
],
"time_elapsed.months": [
"%d bulan yang lalu"
"%d bulan yang lalu"
],
"time_elapsed.years": [
"%d tahun yang lalu"
"%d tahun yang lalu"
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",

View File

@ -35,6 +35,7 @@
"menu.about": "Informazioni",
"menu.export": "Esporta",
"menu.import": "Importa",
"menu.search": "Cerca",
"menu.create_category": "Aggiungi una categoria",
"menu.mark_page_as_read": "Segna questa pagina come letta",
"menu.mark_all_as_read": "Segna tutti gli articoli come letti",
@ -327,6 +328,7 @@
"form.feed.label.urlrewrite_rules": "Regole di riscrittura URL",
"form.feed.label.ignore_http_cache": "Ignora cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Consenti certificati autofirmati o non validi",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Recuperare tramite proxy",
"form.feed.label.disabled": "Non aggiornare questo feed",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -447,6 +449,11 @@
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
"form.integration.matrix_bot_url": "URL del server Matrix",
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
"form.integration.readeck_activate": "Salva gli articoli su Readeck",
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
"form.integration.readeck_api_key": "API key dell'account Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Invia solo URL (invece del contenuto completo)",
"form.integration.shiori_activate": "Salva gli articoli su Shiori",
"form.integration.shiori_endpoint": "Endpoint dell'API di Shiori",
"form.integration.shiori_username": "Nome utente dell'account Shiori",

View File

@ -35,6 +35,7 @@
"menu.about": "ソフトウェア情報",
"menu.export": "エクスポート",
"menu.import": "インポート",
"menu.search": "検索",
"menu.create_category": "カテゴリを作成",
"menu.mark_page_as_read": "このページを既読にする",
"menu.mark_all_as_read": "すべて既読にする",
@ -83,40 +84,33 @@
"entry.shared_entry.title": "公開リンクを開く",
"entry.shared_entry.label": "共有する",
"entry.estimated_reading_time": [
"%d 分で読めます",
"%d 分で読めます"
],
"entry.tags.label": "タグ:",
"page.shared_entries.title": "共有エントリ",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
"%d shared entry"
],
"page.unread.title": "未読",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
"%d unread entry"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
"%d entry in total"
],
"page.starred.title": "星付き",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
"%d starred entry"
],
"page.categories.title": "カテゴリ",
"page.categories.no_feed": "フィードはありません。",
"page.categories.entries": "記事一覧",
"page.categories.feeds": "フィード一覧",
"page.categories.feed_count": [
"%d 件のフィードがあります。",
"%d 件のフィードがあります。"
],
"page.categories_count": [
"%d category",
"%d categories"
"%d category"
],
"page.new_category.title": "新規カテゴリ",
"page.new_user.title": "新規ユーザー",
@ -128,13 +122,11 @@
"page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "既読記事の数",
"page.feeds.error_count": [
"%d 個のエラー",
"%d 個のエラー"
],
"page.history.title": "履歴",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
"%d read entry"
],
"page.import.title": "インポート",
"page.search.title": "検索結果",
@ -215,7 +207,6 @@
"page.settings.webauthn.register": "パスキーを登録する",
"page.settings.webauthn.register.error": "パスキーを登録できません",
"page.settings.webauthn.delete": [
"%d 個のパスキーを削除",
"%d 個のパスキーを削除"
],
"page.login.title": "ログイン",
@ -327,6 +318,7 @@
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "HTTPキャッシュを無視",
"form.feed.label.allow_self_signed_certificates": "自己署名証明書または無効な証明書を許可する",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "プロキシ経由で取得",
"form.feed.label.disabled": "このフィードを更新しない",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -447,6 +439,11 @@
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
"form.integration.readeck_activate": "Readeck に記事を保存する",
"form.integration.readeck_endpoint": "Readeck の API Endpoint",
"form.integration.readeck_api_key": "Readeck の API key",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "URL のみを送信 (完全なコンテンツではなく)",
"form.integration.shiori_activate": "Shiori に記事を保存する",
"form.integration.shiori_endpoint": "Shiori の API Endpoint",
"form.integration.shiori_username": "Shiori の ユーザー名",
@ -466,32 +463,25 @@
"time_elapsed.yesterday": "昨日",
"time_elapsed.now": "今",
"time_elapsed.minutes": [
"%d 分前",
"%d 分前"
],
"time_elapsed.hours": [
"%d 時間前",
"%d 時間前"
],
"time_elapsed.days": [
"%d 日前",
"%d 日前"
],
"time_elapsed.weeks": [
"%d 週間前",
"%d 週間前"
],
"time_elapsed.months": [
"%d か月前",
"%d か月前"
],
"time_elapsed.years": [
"%d 年前",
"%d 年前"
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",

View File

@ -35,6 +35,7 @@
"menu.about": "Over",
"menu.export": "Exporteren",
"menu.import": "Importeren",
"menu.search": "Zoeken",
"menu.create_category": "Categorie toevoegen",
"menu.mark_page_as_read": "Markeer deze pagina als gelezen",
"menu.mark_all_as_read": "Markeer alle items als gelezen",
@ -327,6 +328,7 @@
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
"form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Ophalen via proxy",
"form.feed.label.disabled": "Vernieuw deze feed niet",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -447,6 +449,11 @@
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
"form.integration.matrix_bot_url": "URL van de Matrix-server",
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
"form.integration.readeck_activate": "Opslaan naar Readeck",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API-sleutel",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
"form.integration.shiori_activate": "Opslaan naar Shiori",
"form.integration.shiori_endpoint": "Shiori URL",
"form.integration.shiori_username": "Shiori gebruikersnaam",

View File

@ -35,6 +35,7 @@
"menu.about": "O stronie",
"menu.export": "Eksportuj",
"menu.import": "Importuj",
"menu.search": "Szukaj",
"menu.create_category": "Utwórz kategorię",
"menu.mark_page_as_read": "Oznacz jako przeczytane",
"menu.mark_all_as_read": "Oznacz wszystko jako przeczytane",
@ -84,25 +85,30 @@
"entry.shared_entry.label": "Udostępnianie",
"entry.estimated_reading_time": [
"%d minuta czytania",
"%d minuty czytania",
"%d minut czytania"
],
"entry.tags.label": "Tagi:",
"page.shared_entries.title": "Udostępnione wpisy",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entry",
"%d shared entries"
],
"page.unread.title": "Nieprzeczytane",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entry in total",
"%d entries in total"
],
"page.starred.title": "Oznaczone gwiazdką",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entry",
"%d starred entries"
],
@ -116,6 +122,7 @@
"Jest %d kanałów."
],
"page.categories_count": [
"%d category",
"%d category",
"%d categories"
],
@ -135,6 +142,7 @@
],
"page.history.title": "Historia",
"page.read_entry_count": [
"%d read entry",
"%d read entry",
"%d read entries"
],
@ -330,6 +338,7 @@
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na certyfikaty z podpisem własnym lub nieprawidłowe certyfikaty",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Pobierz przez proxy",
"form.feed.label.disabled": "Nie odświeżaj tego kanału",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -450,6 +459,11 @@
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
"form.integration.matrix_bot_url": "URL serwera Matrix",
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
"form.integration.readeck_activate": "Zapisz artykuły do Readeck",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API key",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
"form.integration.shiori_activate": "Zapisz artykuły do Shiori",
"form.integration.shiori_endpoint": "Shiori URL",
"form.integration.shiori_username": "Login do Shiori",
@ -500,6 +514,7 @@
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",

View File

@ -35,6 +35,7 @@
"menu.about": "Sobre",
"menu.export": "Exportar",
"menu.import": "Importar",
"menu.search": "Buscar",
"menu.create_category": "Criar uma categoria",
"menu.mark_page_as_read": "Marcar essa página como lida",
"menu.mark_all_as_read": "Marcar todos como lido",
@ -327,6 +328,7 @@
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Ignorar cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autoassinados ou inválidos",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disabled": "Não atualizar esta fonte",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.fetch_via_proxy": "Buscar via proxy",
@ -447,6 +449,11 @@
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
"form.integration.matrix_bot_url": "URL do servidor Matrix",
"form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
"form.integration.readeck_activate": "Salvar itens no Readeck",
"form.integration.readeck_endpoint": "Endpoint de API do Readeck",
"form.integration.readeck_api_key": "Chave de API do Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Enviar apenas URL (em vez de conteúdo completo)",
"form.integration.shiori_activate": "Salvar itens no Shiori",
"form.integration.shiori_endpoint": "Endpoint da API do Shiori",
"form.integration.shiori_username": "Nome de usuário do Shiori",

View File

@ -35,6 +35,7 @@
"menu.about": "О приложении",
"menu.export": "Экспорт",
"menu.import": "Импорт",
"menu.search": "Поиск",
"menu.create_category": "Создать категорию",
"menu.mark_page_as_read": "Отметить эту страницу прочитанной",
"menu.mark_all_as_read": "Отметить всё как прочитанное",
@ -84,26 +85,31 @@
"entry.shared_entry.label": "Поделиться",
"entry.estimated_reading_time": [
"%d минута чтения",
"%d минуты чтения",
"%d минут чтения"
],
"entry.tags.label": "Теги:",
"page.shared_entries.title": "Общедоступные статьи",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries",
"%d shared entries"
],
"page.unread.title": "Непрочитанное",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total",
"%d entries in total"
],
"page.starred.title": "Избранное",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries",
"%d starred entries"
],
"page.categories.title": "Категории",
@ -117,6 +123,7 @@
],
"page.categories_count": [
"%d category",
"%d categories",
"%d categories"
],
"page.new_category.title": "Новая категория",
@ -136,6 +143,7 @@
"page.history.title": "История",
"page.read_entry_count": [
"%d read entry",
"%d read entries",
"%d read entries"
],
"page.import.title": "Импорт",
@ -330,6 +338,7 @@
"form.feed.label.apprise_service_urls": "Список ссылок сервисов Apprise, разделенный запятой",
"form.feed.label.ignore_http_cache": "Игнорировать HTTP кеш",
"form.feed.label.allow_self_signed_certificates": "Разрешить самоподписанные или недействительные сертификаты",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Использовать прокси",
"form.feed.label.disabled": "Не обновлять эту подписку",
"form.feed.label.no_media_player": "Отключить медиаплеер (аудио и видео)",
@ -450,6 +459,11 @@
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
"form.integration.readeck_activate": "Сохранять статьи в Readeck",
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
"form.integration.readeck_api_key": "API-ключ Readeck",
"form.integration.readeck_labels": "Теги Readeck",
"form.integration.readeck_only_url": "Отправлять только ссылку (без содержимого)",
"form.integration.shiori_activate": "Сохранять статьи в Shiori",
"form.integration.shiori_endpoint": "Конечная точка Shiori API",
"form.integration.shiori_username": "Имя пользователя Shiori",
@ -500,6 +514,7 @@
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",

View File

@ -35,6 +35,7 @@
"menu.about": "Hakkında",
"menu.export": "Dışarı Aktar",
"menu.import": "İçeri Aktar",
"menu.search": "Ara",
"menu.create_category": "Kategori oluştur",
"menu.mark_page_as_read": "Bu sayfayı okundu olarak işaretle",
"menu.mark_all_as_read": "Tümünü okundu olarak işaretle",
@ -327,6 +328,7 @@
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "HTTP önbelleğini yoksay",
"form.feed.label.allow_self_signed_certificates": "Kendinden imzalı veya geçersiz sertifikalara izin ver",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Proxy ile çek",
"form.feed.label.disabled": "Bu beslemeyi yenileme",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -447,6 +449,11 @@
"form.integration.matrix_bot_password": "Matrix kullanıcısı için şifre",
"form.integration.matrix_bot_url": "Matris sunucusu URL'si",
"form.integration.matrix_bot_chat_id": "Matris odasının kimliği",
"form.integration.readeck_activate": "Makaleleri Readeck'e kaydet",
"form.integration.readeck_endpoint": "Readeck API Uç Noktası",
"form.integration.readeck_api_key": "Readeck API Anahtarı",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Yalnızca URL gönder (tam içerik yerine)",
"form.integration.shiori_activate": "Makaleleri Shiori'e kaydet",
"form.integration.shiori_endpoint": "Shiori API Uç Noktası",
"form.integration.shiori_username": "Shiori Kullanıcı Adı",

View File

@ -35,6 +35,7 @@
"menu.about": "Про додаток",
"menu.export": "Експорт",
"menu.import": "Імпорт",
"menu.search": "Пошук",
"menu.create_category": "Створити категорію",
"menu.mark_page_as_read": "Відмітити цю сторінку як прочитане",
"menu.mark_all_as_read": "Відмітити все як прочитане",
@ -91,20 +92,24 @@
"page.shared_entries.title": "Спильні записи",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries",
"%d shared entries"
],
"page.unread.title": "Непрочитане",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total",
"%d entries in total"
],
"page.starred.title": "З зірочкою",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries",
"%d starred entries"
],
"page.categories.title": "Категорії",
@ -118,6 +123,7 @@
],
"page.categories_count": [
"%d category",
"%d categories",
"%d categories"
],
"page.new_category.title": "Нова категорія",
@ -137,6 +143,7 @@
"page.history.title": "Історія",
"page.read_entry_count": [
"%d read entry",
"%d read entries",
"%d read entries"
],
"page.import.title": "Імпорт",
@ -331,6 +338,7 @@
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Ігнорувати кеш HTTP",
"form.feed.label.allow_self_signed_certificates": "Дозволити сертифікати з власним підписом або недійсні",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Використати проксі-сервер",
"form.feed.label.disabled": "Не оновлювати цю стрічку",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -451,6 +459,11 @@
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
"form.integration.readeck_activate": "Зберігати статті до Readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Ключ API Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Надіслати лише URL (замість повного вмісту)",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
@ -501,6 +514,7 @@
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",

View File

@ -35,6 +35,7 @@
"menu.about": "关于",
"menu.export": "导出",
"menu.import": "导入",
"menu.search": "搜索",
"menu.create_category": "新建分类",
"menu.mark_page_as_read": "标记为已读",
"menu.mark_all_as_read": "全部标为已读",
@ -83,28 +84,23 @@
"entry.shared_entry.title": "打开公共链接",
"entry.shared_entry.label": "分享",
"entry.estimated_reading_time": [
"需要 %d 分钟阅读",
"需要 %d 分钟阅读"
],
"entry.tags.label": "标签:",
"page.shared_entries.title": "已分享的文章",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
"%d shared entry"
],
"page.unread.title": "未读",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
"%d unread entry"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
"%d entry in total"
],
"page.starred.title": "收藏",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
"%d starred entry"
],
"page.categories.title": "分类",
"page.categories.no_feed": "没有源",
@ -114,8 +110,7 @@
"有 %d 个源"
],
"page.categories_count": [
"%d category",
"%d categories"
"%d category"
],
"page.new_category.title": "新分类",
"page.new_user.title": "新用户",
@ -131,8 +126,7 @@
],
"page.history.title": "历史",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
"%d read entry"
],
"page.import.title": "导入",
"page.search.title": "搜索结果",
@ -213,7 +207,6 @@
"page.settings.webauthn.register": "注册 Passkey",
"page.settings.webauthn.register.error": "无法注册 Passkey",
"page.settings.webauthn.delete": [
"删除 %d 个 Passkey",
"删除 %d 个 Passkey"
],
"page.login.title": "登录",
@ -325,6 +318,7 @@
"form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表",
"form.feed.label.ignore_http_cache": "忽略 HTTP 缓存",
"form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "通过代理获取",
"form.feed.label.disabled": "请勿刷新此源",
"form.feed.label.no_media_player": "没有媒体播放器(音频/视频)",
@ -445,6 +439,11 @@
"form.integration.matrix_bot_password": "Matrix Bot 密码",
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
"form.integration.readeck_activate": "保存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端点",
"form.integration.readeck_api_key": "Readeck API 密钥",
"form.integration.readeck_labels": "Readeck 默认标签",
"form.integration.readeck_only_url": "仅发送 URL而不是完整内容",
"form.integration.shiori_activate": "保存文章到 Shiori",
"form.integration.shiori_endpoint": "Shiori API 端点",
"form.integration.shiori_username": "Shiori 用户名",
@ -482,8 +481,7 @@
"%d 年前"
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",

View File

@ -35,6 +35,7 @@
"menu.about": "關於",
"menu.export": "匯出",
"menu.import": "匯入",
"menu.search": "搜尋",
"menu.create_category": "新建分類",
"menu.mark_page_as_read": "將此頁面標記為已讀",
"menu.mark_all_as_read": "全部標為已讀",
@ -83,40 +84,33 @@
"entry.shared_entry.title": "開啟公共連結",
"entry.shared_entry.label": "分享",
"entry.estimated_reading_time": [
"需要 %d 分鐘閱讀",
"需要 %d 分鐘閱讀"
],
"entry.tags.label": "標籤:",
"page.shared_entries.title": "已分享的文章",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
"%d shared entry"
],
"page.unread.title": "未讀",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
"%d unread entry"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
"%d entry in total"
],
"page.starred.title": "收藏",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
"%d starred entry"
],
"page.categories.title": "分類",
"page.categories.no_feed": "沒有Feed",
"page.categories.entries": "檢視內容",
"page.categories.feeds": "檢視Feeds",
"page.categories.feed_count": [
"有 %d 個Feed",
"有 %d 個Feeds"
"有 %d 個Feed"
],
"page.categories_count": [
"%d category",
"%d categories"
"%d category"
],
"page.new_category.title": "新分類",
"page.new_user.title": "新使用者",
@ -128,13 +122,11 @@
"page.feeds.next_check": "下次檢查時間:",
"page.feeds.read_counter": "已讀文章數",
"page.feeds.error_count": [
"%d 錯誤",
"%d 錯誤"
],
"page.history.title": "歷史",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
"%d read entry"
],
"page.import.title": "匯入",
"page.search.title": "搜尋結果",
@ -215,7 +207,6 @@
"page.settings.webauthn.register": "註冊 Passkey",
"page.settings.webauthn.register.error": "無法註冊 Passkey",
"page.settings.webauthn.delete": [
"刪除 %d 個 Passkey",
"刪除 %d 個 Passkey"
],
"page.login.title": "登入",
@ -327,6 +318,7 @@
"form.feed.label.apprise_service_urls": "使用逗號分隔的 Apprise 服務 URL 列表",
"form.feed.label.ignore_http_cache": "忽略 HTTP 快取",
"form.feed.label.allow_self_signed_certificates": "允許自簽章憑證或無效憑證",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "透過代理獲取",
"form.feed.label.disabled": "請勿更新此 Feed",
"form.feed.label.no_media_player": "沒有媒體播放器(音訊/視訊)",
@ -447,6 +439,11 @@
"form.integration.matrix_bot_password": "Matrix 的密碼",
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
"form.integration.readeck_activate": "儲存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端點",
"form.integration.readeck_api_key": "Readeck API 金鑰",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "仅发送 URL而不是完整内容",
"form.integration.shiori_activate": "儲存文章到 Shiori",
"form.integration.shiori_endpoint": "Shiori API 端點",
"form.integration.shiori_username": "Shiori 使用者名稱",
@ -466,32 +463,25 @@
"time_elapsed.yesterday": "昨天",
"time_elapsed.now": "剛剛",
"time_elapsed.minutes": [
"%d 分鐘前",
"%d 分鐘前"
],
"time_elapsed.hours": [
"%d 小時前",
"%d 小時前"
],
"time_elapsed.days": [
"%d 天前",
"%d 天前"
],
"time_elapsed.weeks": [
"%d 周前",
"%d 周前"
],
"time_elapsed.months": [
"%d 月前",
"%d 月前"
],
"time_elapsed.years": [
"%d 年前",
"%d 年前"
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",

View File

@ -67,5 +67,5 @@ type Session struct {
}
func (s *Session) String() string {
return fmt.Sprintf(`ID="%s", Data={%v}`, s.ID, s.Data)
return fmt.Sprintf(`ID=%q, Data={%v}`, s.ID, s.Data)
}

View File

@ -51,6 +51,7 @@ type Feed struct {
FetchViaProxy bool `json:"fetch_via_proxy"`
HideGlobally bool `json:"hide_globally"`
AppriseServiceURLs string `json:"apprise_service_urls"`
DisableHTTP2 bool `json:"disable_http2"`
// Non persisted attributes
Category *Category `json:"category,omitempty"`
@ -150,6 +151,7 @@ type FeedCreationRequest struct {
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
UrlRewriteRules string `json:"urlrewrite_rules"`
DisableHTTP2 bool `json:"disable_http2"`
}
type FeedCreationRequestFromSubscriptionDiscovery struct {
@ -175,6 +177,7 @@ type FeedCreationRequestFromSubscriptionDiscovery struct {
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
UrlRewriteRules string `json:"urlrewrite_rules"`
DisableHTTP2 bool `json:"disable_http2"`
}
// FeedModificationRequest represents the request to update a feed.
@ -199,6 +202,7 @@ type FeedModificationRequest struct {
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
HideGlobally *bool `json:"hide_globally"`
DisableHTTP2 *bool `json:"disable_http2"`
}
// Patch updates a feed with modified values.
@ -282,6 +286,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
if f.HideGlobally != nil {
feed.HideGlobally = *f.HideGlobally
}
if f.DisableHTTP2 != nil {
feed.DisableHTTP2 = *f.DisableHTTP2
}
}
// Feeds is a list of feed

View File

@ -70,6 +70,11 @@ type Integration struct {
AppriseEnabled bool
AppriseURL string
AppriseServicesURL string
ReadeckEnabled bool
ReadeckURL string
ReadeckAPIKey string
ReadeckLabels string
ReadeckOnlyURL bool
ShioriEnabled bool
ShioriURL string
ShioriUsername string

View File

@ -12,4 +12,5 @@ type SubscriptionDiscoveryRequest struct {
Password string `json:"password"`
FetchViaProxy bool `json:"fetch_via_proxy"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
DisableHTTP2 bool `json:"disable_http2"`
}

View File

@ -21,7 +21,7 @@ type UserSession struct {
}
func (u *UserSession) String() string {
return fmt.Sprintf(`ID="%d", UserID="%d", IP="%s", Token="%s"`, u.ID, u.UserID, u.IP, u.Token)
return fmt.Sprintf(`ID=%q, UserID=%q, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)
}
// UseTimezone converts creation date to the given timezone.

View File

@ -43,9 +43,9 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
for _, mediaType := range config.Opts.ProxyMediaTypes() {
switch mediaType {
case "image":
doc.Find("img").Each(func(i int, img *goquery.Selection) {
doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) {
if srcAttrValue, ok := img.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
if shouldProxy(srcAttrValue, proxyOption) {
img.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
@ -55,42 +55,34 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
}
})
doc.Find("picture source").Each(func(i int, sourceElement *goquery.Selection) {
if srcsetAttrValue, ok := sourceElement.Attr("srcset"); ok {
proxifySourceSet(sourceElement, router, proxifyFunction, proxyOption, srcsetAttrValue)
doc.Find("video").Each(func(i int, video *goquery.Selection) {
if posterAttrValue, ok := video.Attr("poster"); ok {
if shouldProxy(posterAttrValue, proxyOption) {
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
}
}
})
case "audio":
doc.Find("audio").Each(func(i int, audio *goquery.Selection) {
doc.Find("audio, audio source").Each(func(i int, audio *goquery.Selection) {
if srcAttrValue, ok := audio.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
if shouldProxy(srcAttrValue, proxyOption) {
audio.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
doc.Find("audio source").Each(func(i int, sourceElement *goquery.Selection) {
if srcAttrValue, ok := sourceElement.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
sourceElement.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
case "video":
doc.Find("video").Each(func(i int, video *goquery.Selection) {
doc.Find("video, video source").Each(func(i int, video *goquery.Selection) {
if srcAttrValue, ok := video.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
if shouldProxy(srcAttrValue, proxyOption) {
video.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
doc.Find("video source").Each(func(i int, sourceElement *goquery.Selection) {
if srcAttrValue, ok := sourceElement.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
sourceElement.SetAttr("src", proxifyFunction(router, srcAttrValue))
if posterAttrValue, ok := video.Attr("poster"); ok {
if shouldProxy(posterAttrValue, proxyOption) {
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
}
}
})
@ -109,7 +101,7 @@ func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFun
imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
for _, imageCandidate := range imageCandidates {
if !isDataURL(imageCandidate.ImageURL) && (proxyOption == "all" || !urllib.IsHTTPS(imageCandidate.ImageURL)) {
if shouldProxy(imageCandidate.ImageURL, proxyOption) {
imageCandidate.ImageURL = proxifyFunction(router, imageCandidate.ImageURL)
}
}
@ -117,6 +109,7 @@ func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFun
element.SetAttr("srcset", imageCandidates.String())
}
func isDataURL(s string) bool {
return strings.HasPrefix(s, "data:")
func shouldProxy(attrValue, proxyOption string) bool {
return !strings.HasPrefix(attrValue, "data:") &&
(proxyOption == "all" || !urllib.IsHTTPS(attrValue))
}

View File

@ -377,3 +377,53 @@ func TestProxyWithImageSourceDataURL(t *testing.T) {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterWithVideo(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "video")
os.Setenv("PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="/proxy/0y3LR8zlx8S8qJkj1qWFOO6x3a-5yf2gLWjGIJV5yyc=/aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tcDQ="></video>`
output := ProxyRewriter(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterVideoPoster(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="https://example.com/video.mp4"></video>`
output := ProxyRewriter(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}

View File

@ -27,7 +27,7 @@ func TestParseAtom03(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -87,7 +87,7 @@ func TestParseAtom03WithoutFeedTitle(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -110,7 +110,7 @@ func TestParseAtom03WithoutEntryTitleButWithLink(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -138,7 +138,7 @@ func TestParseAtom03WithoutEntryTitleButWithSummary(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -166,7 +166,7 @@ func TestParseAtom03WithoutEntryTitleButWithXMLContent(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -197,7 +197,7 @@ func TestParseAtom03WithSummaryOnly(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -228,7 +228,7 @@ func TestParseAtom03WithXMLContent(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -259,7 +259,7 @@ func TestParseAtom03WithBase64Content(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}

View File

@ -184,7 +184,7 @@ func (a *atom10Entry) entryEnclosures() model.EnclosureList {
}
for _, link := range a.Links {
if strings.ToLower(link.Rel) == "enclosure" {
if strings.EqualFold(link.Rel, "enclosure") {
if link.URL == "" {
continue
}

View File

@ -31,7 +31,7 @@ func TestParseAtomSample(t *testing.T) {
</feed>`
feed, err := Parse("http://example.org/feed.xml", bytes.NewBufferString(data))
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -93,7 +93,7 @@ func TestParseFeedWithoutTitle(t *testing.T) {
<updated>2003-12-13T18:30:02Z</updated>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -123,7 +123,7 @@ func TestParseEntryWithoutTitleButWithURL(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -154,7 +154,7 @@ func TestParseEntryWithoutTitleButWithSummary(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -187,7 +187,7 @@ func TestParseEntryWithoutTitleButWithXHTMLContent(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -206,7 +206,7 @@ func TestParseFeedURL(t *testing.T) {
<updated>2003-12-13T18:30:02Z</updated>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -238,7 +238,7 @@ func TestParseFeedWithRelativeURL(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -272,7 +272,7 @@ func TestParseEntryWithRelativeURL(t *testing.T) {
</feed>`
feed, err := Parse("https://example.net/", bytes.NewBufferString(data))
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -298,7 +298,7 @@ func TestParseEntryURLWithTextHTMLType(t *testing.T) {
</feed>`
feed, err := Parse("https://example.net/", bytes.NewBufferString(data))
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -324,7 +324,7 @@ func TestParseEntryURLWithNoRelAndNoType(t *testing.T) {
</feed>`
feed, err := Parse("https://example.net/", bytes.NewBufferString(data))
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -350,7 +350,7 @@ func TestParseEntryURLWithAlternateRel(t *testing.T) {
</feed>`
feed, err := Parse("https://example.net/", bytes.NewBufferString(data))
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -378,7 +378,7 @@ func TestParseEntryTitleWithWhitespaces(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -412,13 +412,13 @@ func TestParseEntryWithPlainTextTitle(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
expected := `AT&T bought by SBC!`
for i := 0; i < 2; i++ {
for i := range 2 {
if feed.Entries[i].Title != expected {
t.Errorf("Incorrect title for entry #%d, got: %q", i, feed.Entries[i].Title)
}
@ -459,7 +459,7 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -497,7 +497,7 @@ func TestParseEntryWithXHTMLTitle(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -524,7 +524,7 @@ func TestParseEntryWithEmptyXHTMLTitle(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -551,7 +551,7 @@ func TestParseEntryWithXHTMLTitleWithoutDiv(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -577,7 +577,7 @@ func TestParseEntryWithNumericCharacterReferenceTitle(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -603,7 +603,7 @@ func TestParseEntryWithDoubleEncodedEntitiesTitle(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -629,7 +629,7 @@ func TestParseEntryWithXHTMLSummary(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -671,13 +671,13 @@ func TestParseEntryWithHTMLSummary(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
expected := `<code>std::unique_ptr&lt;S&gt;</code>`
for i := 0; i < 3; i++ {
for i := range 3 {
if feed.Entries[i].Content != expected {
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
}
@ -723,13 +723,13 @@ func TestParseEntryWithTextSummary(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
expected := `AT&amp;T &lt;S&gt;`
for i := 0; i < 4; i++ {
for i := range 4 {
if feed.Entries[i].Content != expected {
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
}
@ -776,13 +776,13 @@ func TestParseEntryWithTextContent(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
expected := `AT&amp;T &lt;S&gt;`
for i := 0; i < 4; i++ {
for i := range 4 {
if feed.Entries[i].Content != expected {
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
}
@ -821,13 +821,13 @@ func TestParseEntryWithHTMLContent(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
expected := `AT&amp;T bought <b>by SBC</b>!`
for i := 0; i < 3; i++ {
for i := range 3 {
if feed.Entries[i].Content != expected {
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
}
@ -852,7 +852,7 @@ func TestParseEntryWithXHTMLContent(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -881,7 +881,7 @@ func TestParseEntryWithAuthorName(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -910,7 +910,7 @@ func TestParseEntryWithoutAuthorName(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -941,7 +941,7 @@ func TestParseEntryWithMultipleAuthors(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -969,7 +969,7 @@ func TestParseEntryWithoutAuthor(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1000,7 +1000,7 @@ func TestParseFeedWithMultipleAuthors(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1025,7 +1025,7 @@ func TestParseFeedWithoutAuthor(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1075,7 +1075,7 @@ func TestParseEntryWithEnclosures(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1135,7 +1135,7 @@ func TestParseEntryWithoutEnclosureURL(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1168,7 +1168,7 @@ func TestParseEntryWithPublished(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1194,7 +1194,7 @@ func TestParseEntryWithPublishedAndUpdated(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1206,7 +1206,7 @@ func TestParseEntryWithPublishedAndUpdated(t *testing.T) {
func TestParseInvalidXml(t *testing.T) {
data := `garbage`
_, err := Parse("https://example.org/", bytes.NewBufferString(data))
_, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err == nil {
t.Error("Parse should returns an error")
}
@ -1221,7 +1221,7 @@ func TestParseTitleWithSingleQuote(t *testing.T) {
</feed>
`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1240,7 +1240,7 @@ func TestParseTitleWithEncodedSingleQuote(t *testing.T) {
</feed>
`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1259,7 +1259,7 @@ func TestParseTitleWithSingleQuoteAndHTMLType(t *testing.T) {
</feed>
`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1278,7 +1278,7 @@ func TestParseWithHTMLEntity(t *testing.T) {
</feed>
`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1297,7 +1297,7 @@ func TestParseWithInvalidCharacterEntity(t *testing.T) {
</feed>
`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1330,7 +1330,7 @@ A website: http://example.org/</media:description>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1396,7 +1396,7 @@ A website: http://example.org/</media:description>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1467,7 +1467,7 @@ func TestParseRepliesLinkRelationWithHTMLType(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1511,7 +1511,7 @@ func TestParseRepliesLinkRelationWithXHTMLType(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1550,7 +1550,7 @@ func TestParseRepliesLinkRelationWithNoType(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1590,7 +1590,7 @@ func TestAbsoluteCommentsURL(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1631,7 +1631,7 @@ func TestParseFeedWithCategories(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1661,7 +1661,7 @@ func TestParseFeedWithIconURL(t *testing.T) {
<icon>http://example.org/icon.png</icon>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}

View File

@ -46,7 +46,7 @@ type atomLinks []*atomLink
func (a atomLinks) originalLink() string {
for _, link := range a {
if strings.ToLower(link.Rel) == "alternate" {
if strings.EqualFold(link.Rel, "alternate") {
return strings.TrimSpace(link.URL)
}
@ -60,7 +60,7 @@ func (a atomLinks) originalLink() string {
func (a atomLinks) firstLinkWithRelation(relation string) string {
for _, link := range a {
if strings.ToLower(link.Rel) == relation {
if strings.EqualFold(link.Rel, relation) {
return strings.TrimSpace(link.URL)
}
}
@ -70,9 +70,9 @@ func (a atomLinks) firstLinkWithRelation(relation string) string {
func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ...string) string {
for _, link := range a {
if strings.ToLower(link.Rel) == relation {
if strings.EqualFold(link.Rel, relation) {
for _, contentType := range contentTypes {
if strings.ToLower(link.Type) == contentType {
if strings.EqualFold(link.Type, contentType) {
return strings.TrimSpace(link.URL)
}
}

View File

@ -4,7 +4,6 @@
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"bytes"
"encoding/xml"
"fmt"
"io"
@ -18,25 +17,23 @@ type atomFeed interface {
}
// Parse returns a normalized feed struct from a Atom feed.
func Parse(baseURL string, r io.Reader) (*model.Feed, error) {
var buf bytes.Buffer
tee := io.TeeReader(r, &buf)
func Parse(baseURL string, r io.ReadSeeker) (*model.Feed, error) {
var rawFeed atomFeed
if getAtomFeedVersion(tee) == "0.3" {
if getAtomFeedVersion(r) == "0.3" {
rawFeed = new(atom03Feed)
} else {
rawFeed = new(atom10Feed)
}
r.Seek(0, io.SeekStart)
if err := xml_decoder.NewXMLDecoder(&buf).Decode(rawFeed); err != nil {
if err := xml_decoder.NewXMLDecoder(r).Decode(rawFeed); err != nil {
return nil, fmt.Errorf("atom: unable to parse feed: %w", err)
}
return rawFeed.Transform(baseURL), nil
}
func getAtomFeedVersion(data io.Reader) string {
func getAtomFeedVersion(data io.ReadSeeker) string {
decoder := xml_decoder.NewXMLDecoder(data)
for {
token, _ := decoder.Token()

View File

@ -30,7 +30,7 @@ func TestDetectAtom10(t *testing.T) {
</feed>`
version := getAtomFeedVersion(bytes.NewBufferString(data))
version := getAtomFeedVersion(bytes.NewReader([]byte(data)))
if version != "1.0" {
t.Errorf(`Invalid Atom version detected: %s`, version)
}
@ -54,7 +54,7 @@ func TestDetectAtom03(t *testing.T) {
</entry>
</feed>`
version := getAtomFeedVersion(bytes.NewBufferString(data))
version := getAtomFeedVersion(bytes.NewReader([]byte(data)))
if version != "0.3" {
t.Errorf(`Invalid Atom version detected: %s`, version)
}

View File

@ -6,22 +6,25 @@ package date // import "miniflux.app/v2/internal/reader/date"
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
)
// DateFormats taken from github.com/mjibson/goread
// RFC822, RFC850, and RFC1123 formats should be applied only to local times.
var dateFormatsLocalTimesOnly = []string{
time.RFC822, // RSS
time.RFC850,
time.RFC1123,
}
// dateFormats taken from github.com/mjibson/goread
var dateFormats = []string{
time.RFC822, // RSS
time.RFC822Z, // RSS
time.RFC3339, // Atom
time.UnixDate,
time.RubyDate,
time.RFC850,
time.RFC1123Z,
time.RFC1123,
time.ANSIC,
"Mon, 02 Jan 2006 15:04:05 MST -07:00",
"Mon, January 2, 2006, 3:04 PM MST",
@ -314,34 +317,30 @@ var invalidLocalizedDateReplacer = strings.NewReplacer(
// list of commonly found feed date formats.
func Parse(rawInput string) (t time.Time, err error) {
rawInput = strings.TrimSpace(rawInput)
timestamp, err := strconv.ParseInt(rawInput, 10, 64)
if err == nil {
if rawInput == "" {
return t, errors.New(`date parser: empty value`)
}
if timestamp, err := strconv.ParseInt(rawInput, 10, 64); err == nil {
return time.Unix(timestamp, 0), nil
}
processedInput := invalidLocalizedDateReplacer.Replace(rawInput)
processedInput = invalidTimezoneReplacer.Replace(processedInput)
if processedInput == "" {
return t, errors.New(`date parser: empty value`)
for _, layout := range dateFormatsLocalTimesOnly {
if t, err = parseLocalTimeDates(layout, processedInput); err == nil {
return checkTimezoneRange(t), nil
}
}
for _, layout := range dateFormats {
switch layout {
case time.RFC822, time.RFC850, time.RFC1123:
if t, err = parseLocalTimeDates(layout, processedInput); err == nil {
t = checkTimezoneRange(t)
return
}
}
if t, err = time.Parse(layout, processedInput); err == nil {
t = checkTimezoneRange(t)
return
return checkTimezoneRange(t), nil
}
}
err = fmt.Errorf(`date parser: failed to parse date "%s"`, rawInput)
return
return t, fmt.Errorf(`date parser: failed to parse date "%s"`, rawInput)
}
// According to Golang documentation:
@ -369,7 +368,7 @@ func parseLocalTimeDates(layout, ds string) (t time.Time, err error) {
// Avoid "pq: time zone displacement out of range" errors
func checkTimezoneRange(t time.Time) time.Time {
_, offset := t.Zone()
if math.Abs(float64(offset)) > 14*60*60 {
if float64(offset) > 14*60*60 || float64(offset) < -12*60*60 {
t = t.UTC()
}
return t

View File

@ -7,6 +7,14 @@ import (
"testing"
)
func FuzzParse(f *testing.F) {
f.Add("2017-12-22T22:09:49+00:00")
f.Add("Fri, 31 Mar 2023 20:19:00 America/Los_Angeles")
f.Fuzz(func(t *testing.T, date string) {
Parse(date)
})
}
func TestParseEmptyDate(t *testing.T) {
if _, err := Parse(" "); err == nil {
t.Fatalf(`Empty dates should return an error`)
@ -228,14 +236,19 @@ func TestParseWeirdDateFormat(t *testing.T) {
}
func TestParseDateWithTimezoneOutOfRange(t *testing.T) {
date, err := Parse("2023-05-29 00:00:00-23:00")
if err != nil {
t.Errorf(`Unable to parse date: %v`, err)
inputs := []string{
"2023-05-29 00:00:00-13:00",
"2023-05-29 00:00:00+15:00",
}
for _, input := range inputs {
date, err := Parse(input)
_, offset := date.Zone()
if offset != 0 {
t.Errorf(`The offset should be reinitialized to 0 instead of %v because it's out of range`, offset)
if err != nil {
t.Errorf(`Unable to parse date: %v`, err)
}
if _, offset := date.Zone(); offset != 0 {
t.Errorf(`The offset should be reinitialized to 0 instead of %v because it's out of range`, offset)
}
}
}

View File

@ -20,6 +20,7 @@ func (feed *DublinCoreFeedElement) GetSanitizedCreator() string {
// DublinCoreItemElement represents Dublin Core entry XML elements.
type DublinCoreItemElement struct {
DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"`
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`

View File

@ -26,6 +26,7 @@ type RequestBuilder struct {
clientTimeout int
withoutRedirects bool
ignoreTLSErrors bool
disableHTTP2 bool
}
func NewRequestBuilder() *RequestBuilder {
@ -97,6 +98,11 @@ func (r *RequestBuilder) WithoutRedirects() *RequestBuilder {
return r
}
func (r *RequestBuilder) DisableHTTP2(value bool) *RequestBuilder {
r.disableHTTP2 = value
return r
}
func (r *RequestBuilder) IgnoreTLSErrors(value bool) *RequestBuilder {
r.ignoreTLSErrors = value
return r
@ -126,6 +132,14 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
},
}
if r.disableHTTP2 {
transport.ForceAttemptHTTP2 = false
// https://pkg.go.dev/net/http#hdr-HTTP_2
// Programs that must disable HTTP/2 can do so by setting [Transport.TLSNextProto] (for clients) or [Server.TLSNextProto] (for servers) to a non-nil, empty map.
transport.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
}
if r.useClientProxy && r.clientProxyURL != "" {
if proxyURL, err := url.Parse(r.clientProxyURL); err != nil {
slog.Warn("Unable to parse proxy URL",
@ -165,6 +179,8 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
slog.Bool("without_redirects", r.withoutRedirects),
slog.Bool("with_proxy", r.useClientProxy),
slog.String("proxy_url", r.clientProxyURL),
slog.Bool("ignore_tls_errors", r.ignoreTLSErrors),
slog.Bool("disable_http2", r.disableHTTP2),
))
return client.Do(req)

View File

@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googleplay // import "miniflux.app/v2/internal/reader/googleplay"
// Specs:
// https://support.google.com/googleplay/podcasts/answer/6260341
// https://www.google.com/schemas/play-podcasts/1.0/play-podcasts.xsd
type GooglePlayFeedElement struct {
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"`
GooglePlayEmail string `xml:"http://www.google.com/schemas/play-podcasts/1.0 email"`
GooglePlayImage GooglePlayImageElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 image"`
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
GooglePlayCategory GooglePlayCategoryElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 category"`
}
type GooglePlayItemElement struct {
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"`
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
GooglePlayExplicit string `xml:"http://www.google.com/schemas/play-podcasts/1.0 explicit"`
GooglePlayBlock string `xml:"http://www.google.com/schemas/play-podcasts/1.0 block"`
GooglePlayNewFeedURL string `xml:"http://www.google.com/schemas/play-podcasts/1.0 new-feed-url"`
}
type GooglePlayImageElement struct {
Href string `xml:"href,attr"`
}
type GooglePlayCategoryElement struct {
Text string `xml:"text,attr"`
}

View File

@ -67,6 +67,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
subscription.EtagHeader = feedCreationRequest.ETag
subscription.LastModifiedHeader = feedCreationRequest.LastModified
subscription.FeedURL = feedCreationRequest.FeedURL
subscription.DisableHTTP2 = feedCreationRequest.DisableHTTP2
subscription.WithCategoryID(feedCreationRequest.CategoryID)
subscription.CheckedNow()
@ -90,6 +91,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.UseProxy(feedCreationRequest.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
checkFeedIcon(
store,
@ -126,6 +128,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.UseProxy(feedCreationRequest.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(feedCreationRequest.FeedURL))
defer responseHandler.Close()
@ -159,6 +162,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
subscription.Disabled = feedCreationRequest.Disabled
subscription.IgnoreHTTPCache = feedCreationRequest.IgnoreHTTPCache
subscription.AllowSelfSignedCertificates = feedCreationRequest.AllowSelfSignedCertificates
subscription.DisableHTTP2 = feedCreationRequest.DisableHTTP2
subscription.FetchViaProxy = feedCreationRequest.FetchViaProxy
subscription.ScraperRules = feedCreationRequest.ScraperRules
subscription.RewriteRules = feedCreationRequest.RewriteRules
@ -238,6 +242,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.UseProxy(originalFeed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(originalFeed.DisableHTTP2)
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
defer responseHandler.Close()

View File

@ -9,6 +9,7 @@ import (
"io"
"log/slog"
"net/url"
"regexp"
"strings"
"miniflux.app/v2/internal/config"
@ -184,10 +185,10 @@ func (f *IconFinder) DownloadIcon(iconURL string) (*model.Icon, error) {
func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string, error) {
queries := []string{
"link[rel='shortcut icon']",
"link[rel='Shortcut Icon']",
"link[rel='icon shortcut']",
"link[rel='icon']",
"link[rel='icon' i]",
"link[rel='shortcut icon' i]",
"link[rel='icon shortcut' i]",
"link[rel='apple-touch-icon-precomposed.png']",
}
htmlDocumentReader, err := encoding.CharsetReaderFromContentType(contentType, body)
@ -205,18 +206,13 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
slog.Debug("Searching icon URL in HTML document", slog.String("query", query))
doc.Find(query).Each(func(i int, s *goquery.Selection) {
var iconURL string
if href, exists := s.Attr("href"); exists {
iconURL = strings.TrimSpace(href)
}
if iconURL != "" {
iconURLs = append(iconURLs, iconURL)
slog.Debug("Found icon URL in HTML document",
slog.String("query", query),
slog.String("icon_url", iconURL))
if iconURL := strings.TrimSpace(href); iconURL != "" {
iconURLs = append(iconURLs, iconURL)
slog.Debug("Found icon URL in HTML document",
slog.String("query", query),
slog.String("icon_url", iconURL))
}
}
})
}
@ -225,35 +221,23 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
}
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs#syntax
// data:[<mediatype>][;base64],<data>
// data:[<mediatype>][;encoding],<data>
// we consider <mediatype> to be mandatory, and it has to start with `image/`.
// we consider `base64`, `utf8` and the empty string to be the only valid encodings
func parseImageDataURL(value string) (*model.Icon, error) {
var mediaType string
var encoding string
re := regexp.MustCompile(`^data:` +
`(?P<mediatype>image/[^;,]+)` +
`(?:;(?P<encoding>base64|utf8))?` +
`,(?P<data>.+)$`)
if !strings.HasPrefix(value, "data:") {
return nil, fmt.Errorf(`icon: invalid data URL (missing data:) %q`, value)
matches := re.FindStringSubmatch(value)
if matches == nil {
return nil, fmt.Errorf(`icon: invalid data URL %q`, value)
}
value = value[5:]
comma := strings.Index(value, ",")
if comma < 0 {
return nil, fmt.Errorf(`icon: invalid data URL (no comma) %q`, value)
}
data := value[comma+1:]
semicolon := strings.Index(value[0:comma], ";")
if semicolon > 0 {
mediaType = value[0:semicolon]
encoding = value[semicolon+1 : comma]
} else {
mediaType = value[0:comma]
}
if !strings.HasPrefix(mediaType, "image/") {
return nil, fmt.Errorf(`icon: invalid media type %q`, mediaType)
}
mediaType := matches[re.SubexpIndex("mediatype")]
encoding := matches[re.SubexpIndex("encoding")]
data := matches[re.SubexpIndex("data")]
var blob []byte
switch encoding {
@ -271,19 +255,11 @@ func parseImageDataURL(value string) (*model.Icon, error) {
blob = []byte(decodedData)
case "utf8":
blob = []byte(data)
default:
return nil, fmt.Errorf(`icon: unsupported data URL encoding %q`, value)
}
if len(blob) == 0 {
return nil, fmt.Errorf(`icon: empty data URL %q`, value)
}
icon := &model.Icon{
return &model.Icon{
Hash: crypto.HashFromBytes(blob),
Content: blob,
MimeType: mediaType,
}
return icon, nil
}, nil
}

View File

@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package itunes // import "miniflux.app/v2/internal/reader/itunes"
import "strings"
// Specs: https://help.apple.com/itc/podcasts_connect/#/itcb54353390
type ItunesFeedElement struct {
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
ItunesBlock string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd block"`
ItunesCategories []ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
ItunesComplete string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd complete"`
ItunesCopyright string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd copyright"`
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
ItunesImage ItunesImageElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
Keywords string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd keywords"`
ItunesNewFeedURL string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd new-feed-url"`
ItunesOwner ItunesOwnerElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd owner"`
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
ItunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
ItunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"`
}
func (i *ItunesFeedElement) GetItunesCategories() []string {
var categories []string
for _, category := range i.ItunesCategories {
categories = append(categories, category.Text)
if category.SubCategory != nil {
categories = append(categories, category.SubCategory.Text)
}
}
return categories
}
type ItunesItemElement struct {
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
ItunesEpisode string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episode"`
ItunesEpisodeType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episodeType"`
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
ItunesDuration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
ItunesImage ItunesImageElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
ItunesSeason string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd season"`
ItunesSubtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle"`
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
ItunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
ItunesTranscript string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd transcript"`
}
type ItunesImageElement struct {
Href string `xml:"href,attr"`
}
type ItunesCategoryElement struct {
Text string `xml:"text,attr"`
SubCategory *ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
}
type ItunesOwnerElement struct {
Name string `xml:"name"`
Email string `xml:"email"`
}
func (i *ItunesOwnerElement) String() string {
var name string
switch {
case i.Name != "":
name = i.Name
case i.Email != "":
name = i.Email
}
return strings.TrimSpace(name)
}

View File

@ -12,6 +12,7 @@ import (
var textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
// Element represents XML media elements.
// Specs: https://www.rssboard.org/media-rss
type Element struct {
MediaGroups []Group `xml:"http://search.yahoo.com/mrss/ group"`
MediaContents []Content `xml:"http://search.yahoo.com/mrss/ content"`
@ -156,7 +157,7 @@ func (d *Description) HTML() string {
return d.Description
}
content := strings.Replace(d.Description, "\n", "<br>", -1)
content := strings.ReplaceAll(d.Description, "\n", "<br>")
return textLinkRegex.ReplaceAllString(content, `<a href="${1}">${1}</a>`)
}

View File

@ -23,7 +23,7 @@ func (h *Handler) Export(userID int64) (string, error) {
return "", err
}
var subscriptions SubcriptionList
subscriptions := make(SubcriptionList, 0, len(feeds))
for _, feed := range feeds {
subscriptions = append(subscriptions, &Subcription{
Title: feed.Title,

View File

@ -37,7 +37,7 @@ func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category strin
CategoryName: category,
})
} else if outline.Outlines.HasChildren() {
subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.Text)...)
subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.GetTitle())...)
}
}
return subscriptions

View File

@ -81,7 +81,7 @@ func TestParseOpmlWithCategories(t *testing.T) {
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 3)
}
for i := 0; i < len(subscriptions); i++ {
for i := range len(subscriptions) {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
@ -114,7 +114,7 @@ func TestParseOpmlWithEmptyTitleAndEmptySiteURL(t *testing.T) {
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
}
for i := 0; i < len(subscriptions); i++ {
for i := range len(subscriptions) {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
@ -129,10 +129,10 @@ func TestParseOpmlVersion1(t *testing.T) {
<dateCreated>Wed, 13 Mar 2019 11:51:41 GMT</dateCreated>
</head>
<body>
<outline title="Feed 1">
<outline title="Category 1">
<outline type="rss" title="Feed 1" xmlUrl="http://example.org/feed1/" htmlUrl="http://example.org/1"></outline>
</outline>
<outline title="Feed 2">
<outline title="Category 2">
<outline type="rss" title="Feed 2" xmlUrl="http://example.org/feed2/" htmlUrl="http://example.org/2"></outline>
</outline>
</body>
@ -140,8 +140,8 @@ func TestParseOpmlVersion1(t *testing.T) {
`
var expected SubcriptionList
expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: ""})
expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryName: ""})
expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: "Category 1"})
expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryName: "Category 2"})
subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil {
@ -152,7 +152,7 @@ func TestParseOpmlVersion1(t *testing.T) {
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
}
for i := 0; i < len(subscriptions); i++ {
for i := range len(subscriptions) {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
@ -186,7 +186,7 @@ func TestParseOpmlVersion1WithoutOuterOutline(t *testing.T) {
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
}
for i := 0; i < len(subscriptions); i++ {
for i := range len(subscriptions) {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
@ -228,7 +228,7 @@ func TestParseOpmlVersion1WithSeveralNestedOutlines(t *testing.T) {
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 3)
}
for i := 0; i < len(subscriptions); i++ {
for i := range len(subscriptions) {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
@ -250,7 +250,7 @@ func TestParseOpmlWithInvalidCharacterEntity(t *testing.T) {
`
var expected SubcriptionList
expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/a&b", SiteURL: "http://example.org/c&d", CategoryName: ""})
expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/a&b", SiteURL: "http://example.org/c&d", CategoryName: "Feed 1"})
subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil {
@ -261,7 +261,7 @@ func TestParseOpmlWithInvalidCharacterEntity(t *testing.T) {
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 1)
}
for i := 0; i < len(subscriptions); i++ {
for i := range len(subscriptions) {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}

View File

@ -38,14 +38,14 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument {
opmlDocument.Header.DateCreated = time.Now().Format("Mon, 02 Jan 2006 15:04:05 MST")
groupedSubs := groupSubscriptionsByFeed(subscriptions)
var categories []string
categories := make([]string, 0, len(groupedSubs))
for k := range groupedSubs {
categories = append(categories, k)
}
sort.Strings(categories)
for _, categoryName := range categories {
category := opmlOutline{Text: categoryName}
category := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))}
for _, subscription := range groupedSubs[categoryName] {
category.Outlines = append(category.Outlines, opmlOutline{
Title: subscription.Title,

View File

@ -8,6 +8,62 @@ import (
"testing"
)
func FuzzParse(f *testing.F) {
f.Add("https://z.org", `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://z.org/"/>
<link href="/k"/>
<updated>2003-12-13T18:30:02Z</updated>
<author><name>John Doe</name></author>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
<entry>
<title>a</title>
<link href="http://example.org/b"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>c</summary>
</entry>
</feed>`)
f.Add("https://z.org", `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>a</title>
<link>http://z.org</link>
<item>
<title>a</title>
<link>http://z.org</link>
<description>d</description>
<pubDate>Tue, 03 Jun 2003 09:39:21 GMT</pubDate>
<guid>l</guid>
</item>
</channel>
</rss>`)
f.Add("https://z.org", `<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/">
<channel>
<title>a</title>
<link>http://z.org/</link>
</channel>
<item>
<title>a</title>
<link>/</link>
<description>c</description>
</item>
</rdf:RDF>`)
f.Add("http://z.org", `{
"version": "http://jsonfeed.org/version/1",
"title": "a",
"home_page_url": "http://z.org/",
"feed_url": "http://z.org/a.json",
"items": [
{"id": "2","content_text": "a","url": "https://z.org/2"},
{"id": "1","content_html": "<a","url":"http://z.org/1"}]}`)
f.Fuzz(func(t *testing.T, url string, data string) {
ParseFeed(url, strings.NewReader(data))
})
}
func TestParseAtom(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

View File

@ -8,6 +8,7 @@ import (
"fmt"
"log/slog"
"regexp"
"slices"
"strconv"
"time"
@ -25,7 +26,7 @@ import (
)
var (
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)`)
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
@ -71,6 +72,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.UseProxy(feed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
content, scraperErr := scraper.ScrapeWebsite(
requestBuilder,
@ -115,13 +117,9 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
if feed.BlocklistRules != "" {
var containsBlockedTag bool = false
for _, tag := range entry.Tags {
if matchField(feed.BlocklistRules, tag) {
containsBlockedTag = true
break
}
}
containsBlockedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
return matchField(feed.BlocklistRules, tag)
})
if matchField(feed.BlocklistRules, entry.URL) || matchField(feed.BlocklistRules, entry.Title) || matchField(feed.BlocklistRules, entry.Author) || containsBlockedTag {
slog.Debug("Blocking entry based on rule",
@ -140,13 +138,9 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
if feed.KeeplistRules != "" {
var containsAllowedTag bool = false
for _, tag := range entry.Tags {
if matchField(feed.KeeplistRules, tag) {
containsAllowedTag = true
break
}
}
containsAllowedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
return matchField(feed.KeeplistRules, tag)
})
if matchField(feed.KeeplistRules, entry.URL) || matchField(feed.KeeplistRules, entry.Title) || matchField(feed.KeeplistRules, entry.Author) || containsAllowedTag {
slog.Debug("Allow entry based on rule",
@ -188,6 +182,7 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.UseProxy(feed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
content, scraperErr := scraper.ScrapeWebsite(
requestBuilder,
@ -209,7 +204,9 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
if content != "" {
entry.Content = content
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
}
rewrite.Rewriter(websiteURL, entry, entry.Feed.RewriteRules)
@ -287,7 +284,9 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
}
// Handle YT error case and non-YT entries.
if entry.ReadingTime == 0 {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
}
}

View File

@ -12,7 +12,7 @@ import (
)
// Parse returns a normalized feed struct from a RDF feed.
func Parse(baseURL string, data io.Reader) (*model.Feed, error) {
func Parse(baseURL string, data io.ReadSeeker) (*model.Feed, error) {
feed := new(rdfFeed)
if err := xml.NewXMLDecoder(data).Decode(feed); err != nil {
return nil, fmt.Errorf("rdf: unable to parse feed: %w", err)

View File

@ -75,7 +75,7 @@ func TestParseRDFSample(t *testing.T) {
</rdf:RDF>`
feed, err := Parse("http://xml.com/pub/rdf.xml", bytes.NewBufferString(data))
feed, err := Parse("http://xml.com/pub/rdf.xml", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -186,7 +186,7 @@ func TestParseRDFSampleWithDublinCore(t *testing.T) {
</rdf:RDF>`
feed, err := Parse("http://meerkat.oreillynet.com/feed.rdf", bytes.NewBufferString(data))
feed, err := Parse("http://meerkat.oreillynet.com/feed.rdf", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -253,7 +253,7 @@ func TestParseItemWithOnlyFeedAuthor(t *testing.T) {
</item>
</rdf:RDF>`
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewBufferString(data))
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -278,7 +278,7 @@ func TestParseItemRelativeURL(t *testing.T) {
</item>
</rdf:RDF>`
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewBufferString(data))
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -307,7 +307,7 @@ func TestParseItemWithoutLink(t *testing.T) {
</item>
</rdf:RDF>`
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewBufferString(data))
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -338,7 +338,7 @@ func TestParseItemWithDublicCoreDate(t *testing.T) {
</item>
</rdf:RDF>`
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
feed, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -366,7 +366,7 @@ func TestParseItemWithEncodedHTMLInDCCreatorField(t *testing.T) {
</item>
</rdf:RDF>`
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
feed, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -392,7 +392,7 @@ func TestParseItemWithoutDate(t *testing.T) {
</item>
</rdf:RDF>`
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
feed, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -406,7 +406,7 @@ func TestParseItemWithoutDate(t *testing.T) {
func TestParseItemWithEncodedHTMLTitle(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/">
<channel>
<title>Example</title>
<link>http://example.org</link>
@ -419,19 +419,19 @@ func TestParseItemWithEncodedHTMLTitle(t *testing.T) {
</item>
</rdf:RDF>`
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
feed, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if feed.Entries[0].Title != `AT&T` {
t.Errorf("Incorrect entry title, got: %v", feed.Entries[0].Title)
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
func TestParseInvalidXml(t *testing.T) {
data := `garbage`
_, err := Parse("http://example.org", bytes.NewBufferString(data))
_, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
if err == nil {
t.Fatal("Parse should returns an error")
}
@ -446,7 +446,7 @@ func TestParseFeedWithHTMLEntity(t *testing.T) {
</channel>
</rdf:RDF>`
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
feed, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -465,7 +465,7 @@ func TestParseFeedWithInvalidCharacterEntity(t *testing.T) {
</channel>
</rdf:RDF>`
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
feed, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -502,7 +502,7 @@ func TestParseFeedWithURLWrappedInSpaces(t *testing.T) {
<item rdf:about="http://biorxiv.org/cgi/content/short/857789v1?rss=1">
<title>
<![CDATA[
Microscale Collagen and Fibroblast Interactions Enhance Primary Human Hepatocyte Functions in 3-Dimensional Models
Microscale Collagen and Fibroblast Interactions Enhance Primary Human Hepatocyte Functions in 3-Dimensional Models
]]>
</title>
<link>
@ -521,7 +521,7 @@ func TestParseFeedWithURLWrappedInSpaces(t *testing.T) {
</item>
</rdf:RDF>`
feed, err := Parse("http://biorxiv.org", bytes.NewBufferString(data))
feed, err := Parse("http://biorxiv.org", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -556,7 +556,7 @@ func TestParseRDFWithContentEncoded(t *testing.T) {
</item>
</rdf:RDF>`
feed, err := Parse("http://example.org/", bytes.NewBufferString(data))
feed, err := Parse("http://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -568,7 +568,7 @@ func TestParseRDFWithContentEncoded(t *testing.T) {
expected := `<p>Test</p>`
result := feed.Entries[0].Content
if result != expected {
t.Errorf(`Unexpected entry URL, got %q instead of %q`, result, expected)
t.Errorf(`Unexpected entry content, got %q instead of %q`, result, expected)
}
}
@ -589,7 +589,7 @@ func TestParseRDFWithEncodedHTMLDescription(t *testing.T) {
</item>
</rdf:RDF>`
feed, err := Parse("http://example.org/", bytes.NewBufferString(data))
feed, err := Parse("http://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -601,6 +601,105 @@ func TestParseRDFWithEncodedHTMLDescription(t *testing.T) {
expected := `AT&amp;T <img src="https://example.org/img.png"></a>`
result := feed.Entries[0].Content
if result != expected {
t.Errorf(`Unexpected entry URL, got %v instead of %v`, result, expected)
t.Errorf(`Unexpected entry content, got %v instead of %v`, result, expected)
}
}
func TestParseRDFItemWithDuplicateTitleElement(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://purl.org/rss/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Example Feed</title>
<link>http://example.org/</link>
</channel>
<item>
<title>Item Title</title>
<dc:title/>
<link>http://example.org/</link>
<description>Test</description>
</item>
</rdf:RDF>`
feed, err := Parse("http://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))
}
expected := `Item Title`
result := feed.Entries[0].Title
if result != expected {
t.Errorf(`Unexpected entry title, got %q instead of %q`, result, expected)
}
}
func TestParseRDFItemWithDublinCoreTitleElement(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://purl.org/rss/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Example Feed</title>
<link>http://example.org/</link>
</channel>
<item>
<dc:title>Dublin Core Title</dc:title>
<link>http://example.org/</link>
<description>Test</description>
</item>
</rdf:RDF>`
feed, err := Parse("http://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))
}
expected := `Dublin Core Title`
result := feed.Entries[0].Title
if result != expected {
t.Errorf(`Unexpected entry title, got %q instead of %q`, result, expected)
}
}
func TestParseRDFItemWitEmptyTitleElement(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://purl.org/rss/1.0/">
<channel>
<title>Example Feed</title>
<link>http://example.org/</link>
</channel>
<item>
<title> </title>
<link>http://example.org/item</link>
<description>Test</description>
</item>
</rdf:RDF>`
feed, err := Parse("http://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))
}
expected := `http://example.org/item`
result := feed.Entries[0].Title
if result != expected {
t.Errorf(`Unexpected entry title, got %q instead of %q`, result, expected)
}
}

View File

@ -58,7 +58,7 @@ func (r *rdfFeed) Transform(baseURL string) *model.Feed {
}
type rdfItem struct {
Title string `xml:"title"`
Title string `xml:"http://purl.org/rss/1.0/ title"`
Link string `xml:"link"`
Description string `xml:"description"`
dublincore.DublinCoreItemElement
@ -72,11 +72,21 @@ func (r *rdfItem) Transform() *model.Entry {
entry.Content = r.entryContent()
entry.Hash = r.entryHash()
entry.Date = r.entryDate()
if entry.Title == "" {
entry.Title = entry.URL
}
return entry
}
func (r *rdfItem) entryTitle() string {
return html.UnescapeString(strings.TrimSpace(r.Title))
for _, title := range []string{r.Title, r.DublinCoreTitle} {
title = strings.TrimSpace(title)
if title != "" {
return html.UnescapeString(title)
}
}
return ""
}
func (r *rdfItem) entryContent() string {

View File

@ -132,12 +132,15 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
}
})
output.Write([]byte("</div>"))
output.WriteString("</div>")
return output.String()
}
func removeUnlikelyCandidates(document *goquery.Document) {
document.Find("*").Not("html,body").Each(func(i int, s *goquery.Selection) {
document.Find("*").Each(func(i int, s *goquery.Selection) {
if s.Length() == 0 || s.Get(0).Data == "html" || s.Get(0).Data == "body" {
return
}
class, _ := s.Attr("class")
id, _ := s.Attr("id")
str := class + id

View File

@ -17,15 +17,23 @@ import (
// EstimateReadingTime returns the estimated reading time of an article in minute.
func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed int) int {
sanitizedContent := sanitizer.StripTags(content)
langInfo := whatlanggo.Detect(sanitizedContent)
var timeToReadInt int
if langInfo.IsReliable() && (langInfo.Lang == whatlanggo.Jpn || langInfo.Lang == whatlanggo.Cmn || langInfo.Lang == whatlanggo.Kor) {
timeToReadInt = int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))
} else {
nbOfWords := len(strings.Fields(sanitizedContent))
timeToReadInt = int(math.Ceil(float64(nbOfWords) / float64(defaultReadingSpeed)))
// Litterature on language detection says that around 100 signes is enough, we're safe here.
truncationPoint := int(math.Min(float64(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)
return timeToReadInt
if langInfo.IsReliable() {
return int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))
}
nbOfWords := len(strings.Fields(sanitizedContent))
return int(math.Ceil(float64(nbOfWords) / float64(defaultReadingSpeed)))
}

View File

@ -5,8 +5,10 @@ package readingtime
import "testing"
func TestEstimateReadingTimeInEnglish(t *testing.T) {
sampleText := `
var samples = map[string]string{
"shortenglish": `This is a short paragraph in english, less than 250 chars.`,
"shortchinese": ` 労問委格名町違載式新青脂通由。割止書円画民京般著治登門画拡下。有国同観教田美森素説砂者徴多。上治速相支存色分繰年活元事集遣逆山`,
"english": `
In turpis lacus, sollicitudin non accumsan sed, suscipit eget magna. Morbi id
neque enim. Aenean ac lacus consectetur, accumsan elit ac, suscipit dui. Donec
congue mi et nisl bibendum, venenatis fringilla orci tristique. Nullam ullamcorper
@ -35,27 +37,52 @@ func TestEstimateReadingTimeInEnglish(t *testing.T) {
turpis. Sed semper eu urna sit amet malesuada. Suspendisse blandit condimentum elit,
in scelerisque tellus convallis eu. Nunc eleifend sem et mauris vestibulum
mattis. Praesent ultricies pellentesque eros non posuere.
`
`,
"chinese": `
労問委格名町違載式新青脂通由割止書円画民京般著治登門画拡下有国同観教田美森素説砂者徴多上治速相支存色分繰年活元事集遣逆山身消年森発世財間世変悲原記潟旅好手真今現通浪口特愛始信川節身方一表著購郁不使権草定内防並要更一条露加載交源図訴際属年券重供健三洗事北残却女鮎朝分要廷込宣政愛無投事
readingTime := EstimateReadingTime(sampleText, 200, 500)
if readingTime != 2 {
t.Errorf(`Wrong reading time, got %d instead of 2`, readingTime)
問警技亮参沼洗請米物模人誰探重午局新戦報投性病庭典向載問千著書故表視新権最石車音端乏大白僚三掲局係仕表広無旧見要最裁額寄済生年余講前本次載隊劇権成観始応泉早高拓了経地本稼室目犯井出暮載必広傷内校岡公南散広転行別釈康運行関本掲隠泉傷退報告独変年換差取予口男旅挑講禁姿出芳工類胸管払時済潟髪内豊
康浴部問玲玉追球化就店岡問画路投施先太業阪能敏所陸不供探掲方用手右演社援発示竹育対橋除際愛功旬転好使公利時改本項輸属嘆員複携者地剤天政朝戸祝言月接住世黙極者議編連囲淑覧重弾必治物健賄開頂外称豊開名銀戸院政稿調励廃演手生告題営味董演何南峰貨学横公得行提大品回猿齢利込家前役把煎天代者内身慢作業署間地日
中個興本広坂態掲神中能等無滞長対号処月画界意気様党目購栃欠歌暮一耳供意盛四俊健必財下画例本判著堺要北王宮大攻人水一備治首闘振円分建前趣校目少供午見掲岡安画入情薦続土世始診読格七久改急目斉実配正性止月模多様更社発掲雪奇芸量全兵経負予転済反問止下生買再無旅的模治明以共会必華浅知館版領送
`,
"korean": `
세계 인권 선언(世界人權宣言, 영어: Universal Declaration of Human Rights, UDHR) 1948 12 10 파리에서 열린 제3회 유엔 총회에서 채택된 인권에 관한 세계 선언문이다.[1] 2 세계대전 전후로 세계에 만연하였던 인권침해 사태에 대한 인류의 반성을 촉구하고, 모든 인간의 기본적 권리를 존중해야 한다는 유엔 헌장의 취지를 구체화 하였다.[2] 시민적, 정치적 권리가 중심이지만 노동자의 단결권, 교육에 관한 권리, 예술을 향유할 권리 경제적, 사회적, 문화적 권리에 대하여서도 규정하고 있다.[1]
초안은 1946 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 (Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58 국가 48 국가가 찬성하여 유엔 총회 결의 217 A (III) 승인되었다.
초안은 1946 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 (Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58 국가 48 국가가 찬성하여 유엔 총회 결의 217 A (III) 승인되었다.
초안은 1946 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 (Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58 국가 48 국가가 찬성하여 유엔 총회 결의 217 A (III) 승인되었다.
초안은 1946 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 (Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58 국가 48 국가가 찬성하여 유엔 총회 결의 217 A (III) 승인되었다.
초안은 1946 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 (Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58 국가 48 국가가 찬성하여 유엔 총회 결의 217 A (III) 승인되었다.
초안은 1946 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 (Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58 국가 48 국가가 찬성하여 유엔 총회 결의 217 A (III) 승인되었다.
초안은 1946 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 (Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58 국가 48 국가가 찬성하여 유엔 총회 결의 217 A (III) 승인되었다.
초안은 1946 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 (Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58 국가 48 국가가 찬성하여 유엔 총회 결의 217 A (III) 승인되었다.
초안은 1946 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 (Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58 국가 48 국가가 찬성하여 유엔 총회 결의 217 A (III) 승인되었다.
`,
}
func TestEstimateReadingTime(t *testing.T) {
expected := map[string]int{
"shortenglish": 1,
"shortchinese": 1,
"english": 2,
"chinese": 2,
"korean": 5,
}
for language, sample := range samples {
got := EstimateReadingTime(sample, 200, 500)
want := expected[language]
if got != want {
t.Errorf(`Wrong reading time, got %d instead of %d for %s`, got, want, language)
}
}
}
func TestEstimateReadingTimeInChinese(t *testing.T) {
sampleText := `
労問委格名町違載式新青脂通由割止書円画民京般著治登門画拡下有国同観教田美森素説砂者徴多上治速相支存色分繰年活元事集遣逆山身消年森発世財間世変悲原記潟旅好手真今現通浪口特愛始信川節身方一表著購郁不使権草定内防並要更一条露加載交源図訴際属年券重供健三洗事北残却女鮎朝分要廷込宣政愛無投事
問警技亮参沼洗請米物模人誰探重午局新戦報投性病庭典向載問千著書故表視新権最石車音端乏大白僚三掲局係仕表広無旧見要最裁額寄済生年余講前本次載隊劇権成観始応泉早高拓了経地本稼室目犯井出暮載必広傷内校岡公南散広転行別釈康運行関本掲隠泉傷退報告独変年換差取予口男旅挑講禁姿出芳工類胸管払時済潟髪内豊
康浴部問玲玉追球化就店岡問画路投施先太業阪能敏所陸不供探掲方用手右演社援発示竹育対橋除際愛功旬転好使公利時改本項輸属嘆員複携者地剤天政朝戸祝言月接住世黙極者議編連囲淑覧重弾必治物健賄開頂外称豊開名銀戸院政稿調励廃演手生告題営味董演何南峰貨学横公得行提大品回猿齢利込家前役把煎天代者内身慢作業署間地日
中個興本広坂態掲神中能等無滞長対号処月画界意気様党目購栃欠歌暮一耳供意盛四俊健必財下画例本判著堺要北王宮大攻人水一備治首闘振円分建前趣校目少供午見掲岡安画入情薦続土世始診読格七久改急目斉実配正性止月模多様更社発掲雪奇芸量全兵経負予転済反問止下生買再無旅的模治明以共会必華浅知館版領送
`
readingTime := EstimateReadingTime(sampleText, 200, 500)
if readingTime != 2 {
t.Errorf(`Wrong reading time, got %d instead of 2`, readingTime)
func BenchmarkEstimateReadingTime(b *testing.B) {
for range b.N {
for _, sample := range samples {
EstimateReadingTime(sample, 200, 500)
}
}
}

View File

@ -20,9 +20,9 @@ import (
)
var (
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)`)
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
youtubeIdRegex = regexp.MustCompile(`youtube_id"?\s*[:=]\s*"([a-zA-Z0-9_-]{11})"`)
invidioRegex = regexp.MustCompile(`https?:\/\/(.*)\/watch\?v=(.*)`)
invidioRegex = regexp.MustCompile(`https?://(.*)/watch\?v=(.*)`)
imgRegex = regexp.MustCompile(`<img [^>]+>`)
textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
)
@ -292,7 +292,7 @@ func addInvidiousVideo(entryURL, entryContent string) string {
func addPDFLink(entryURL, entryContent string) string {
if strings.HasSuffix(entryURL, ".pdf") {
return fmt.Sprintf(`<a href="%s">PDF</a><br>%s`, entryURL, entryContent)
return fmt.Sprintf(`<a href=%q>PDF</a><br>%s`, entryURL, entryContent)
}
return entryContent
}
@ -302,7 +302,7 @@ func replaceTextLinks(input string) string {
}
func replaceLineFeeds(input string) string {
return strings.Replace(input, "\n", "<br>", -1)
return strings.ReplaceAll(input, "\n", "<br>")
}
func replaceCustom(entryContent string, searchTerm string, replaceTerm string) string {

View File

@ -671,3 +671,35 @@ func TestAddHackerNewsLinksUsingOpener(t *testing.T) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestAddImageTitle(t *testing.T) {
testEntry := &model.Entry{
Title: `A title`,
Content: `
<img src="pif" title="pouf">
<img src="pif" title="pouf" alt='"onerror=alert(1) a="'>
<img src="pif" title="pouf" alt='&quot;onerror=alert(1) a=&quot'>
<img src="pif" title="pouf" alt=';&amp;quot;onerror=alert(1) a=;&amp;quot;'>
<img src="pif" alt="pouf" title='"onerror=alert(1) a="'>
<img src="pif" alt="pouf" title='&quot;onerror=alert(1) a=&quot'>
<img src="pif" alt="pouf" title=';&amp;quot;onerror=alert(1) a=;&amp;quot;'>
`,
}
controlEntry := &model.Entry{
Title: `A title`,
Content: `<figure><img src="pif" alt=""/><figcaption><p>pouf</p></figcaption></figure>
<figure><img src="pif" alt="" onerror="alert(1)" a=""/><figcaption><p>pouf</p></figcaption></figure>
<figure><img src="pif" alt="" onerror="alert(1)" a=""/><figcaption><p>pouf</p></figcaption></figure>
<figure><img src="pif" alt=";&#34;onerror=alert(1) a=;&#34;"/><figcaption><p>pouf</p></figcaption></figure>
<figure><img src="pif" alt="pouf"/><figcaption><p>&#34;onerror=alert(1) a=&#34;</p></figcaption></figure>
<figure><img src="pif" alt="pouf"/><figcaption><p>&#34;onerror=alert(1) a=&#34;</p></figcaption></figure>
<figure><img src="pif" alt="pouf"/><figcaption><p>;&amp;quot;onerror=alert(1) a=;&amp;quot;</p></figcaption></figure>
`,
}
Rewriter("https://example.org/article", testEntry, `add_image_title`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}

View File

@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package rss // import "miniflux.app/v2/internal/reader/rss"
import "strings"
type AtomAuthor struct {
Author AtomPerson `xml:"http://www.w3.org/2005/Atom author"`
}
func (a *AtomAuthor) String() string {
return a.Author.String()
}
type AtomPerson struct {
Name string `xml:"name"`
Email string `xml:"email"`
}
func (a *AtomPerson) String() string {
var name string
switch {
case a.Name != "":
name = a.Name
case a.Email != "":
name = a.Email
}
return strings.TrimSpace(name)
}
type AtomLink struct {
URL string `xml:"href,attr"`
Type string `xml:"type,attr"`
Rel string `xml:"rel,attr"`
Length string `xml:"length,attr"`
}
type AtomLinks struct {
Links []*AtomLink `xml:"http://www.w3.org/2005/Atom link"`
}

View File

@ -12,9 +12,11 @@ import (
)
// Parse returns a normalized feed struct from a RSS feed.
func Parse(baseURL string, data io.Reader) (*model.Feed, error) {
func Parse(baseURL string, data io.ReadSeeker) (*model.Feed, error) {
feed := new(rssFeed)
if err := xml.NewXMLDecoder(data).Decode(feed); err != nil {
decoder := xml.NewXMLDecoder(data)
decoder.DefaultSpace = "rss"
if err := decoder.Decode(feed); err != nil {
return nil, fmt.Errorf("rss: unable to parse feed: %w", err)
}
return feed.Transform(baseURL), nil

View File

@ -58,7 +58,7 @@ func TestParseRss2Sample(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("http://liftoff.msfc.nasa.gov/rss.xml", bytes.NewBufferString(data))
feed, err := Parse("http://liftoff.msfc.nasa.gov/rss.xml", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -117,7 +117,7 @@ func TestParseFeedWithoutTitle(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -138,7 +138,7 @@ func TestParseEntryWithoutTitleAndDescription(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -162,7 +162,7 @@ func TestParseEntryWithoutTitleButWithDescription(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -185,7 +185,7 @@ func TestParseEntryWithMediaTitle(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -207,7 +207,7 @@ func TestParseEntryWithDCTitleOnly(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -228,7 +228,7 @@ func TestParseEntryWithoutLink(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -256,7 +256,7 @@ func TestParseEntryWithOnlyGuidPermalink(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -282,7 +282,7 @@ func TestParseEntryWithAtomLink(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -300,12 +300,12 @@ func TestParseEntryWithMultipleAtomLinks(t *testing.T) {
<item>
<title>Test</title>
<atom:link rel="payment" href="https://example.org/a" />
<atom:link rel="http://foobar.tld" href="https://example.org/b" />
<atom:link rel="alternate" href="https://example.org/b" />
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -325,7 +325,7 @@ func TestParseFeedURLWithAtomLink(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -353,7 +353,7 @@ func TestParseFeedWithWebmaster(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -380,7 +380,7 @@ func TestParseFeedWithManagingEditor(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -407,7 +407,7 @@ func TestParseEntryWithAuthorAndInnerHTML(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -430,13 +430,13 @@ func TestParseEntryWithAuthorAndCDATA(t *testing.T) {
<title>Test</title>
<link>https://example.org/item</link>
<author>
by <![CDATA[Foo Bar]]>
<![CDATA[by Foo Bar]]>
</author>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -447,38 +447,6 @@ func TestParseEntryWithAuthorAndCDATA(t *testing.T) {
}
}
func TestParseEntryWithNonStandardAtomAuthor(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>Example</title>
<link>https://example.org/</link>
<atom:link href="https://example.org/rss" type="application/rss+xml" rel="self"></atom:link>
<item>
<title>Test</title>
<link>https://example.org/item</link>
<author xmlns:author="http://www.w3.org/2005/Atom">
<name>Foo Bar</name>
<title>Vice President</title>
<department/>
<company>FooBar Inc.</company>
</author>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
expected := "Foo Bar"
result := feed.Entries[0].Author
if result != expected {
t.Errorf("Incorrect entry author, got %q instead of %q", result, expected)
}
}
func TestParseEntryWithAtomAuthorEmail(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
@ -496,7 +464,7 @@ func TestParseEntryWithAtomAuthorEmail(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -508,7 +476,7 @@ func TestParseEntryWithAtomAuthorEmail(t *testing.T) {
}
}
func TestParseEntryWithAtomAuthor(t *testing.T) {
func TestParseEntryWithAtomAuthorName(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
@ -525,7 +493,7 @@ func TestParseEntryWithAtomAuthor(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -551,7 +519,7 @@ func TestParseEntryWithDublinCoreAuthor(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -577,7 +545,7 @@ func TestParseEntryWithItunesAuthor(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -603,7 +571,7 @@ func TestParseFeedWithItunesAuthor(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -632,7 +600,7 @@ func TestParseFeedWithItunesOwner(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -660,7 +628,7 @@ func TestParseFeedWithItunesOwnerEmail(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -686,7 +654,7 @@ func TestParseEntryWithGooglePlayAuthor(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -712,7 +680,7 @@ func TestParseFeedWithGooglePlayAuthor(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -740,7 +708,7 @@ func TestParseEntryWithDublinCoreDate(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -768,7 +736,7 @@ func TestParseEntryWithContentEncoded(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -792,7 +760,7 @@ func TestParseEntryWithFeedBurnerLink(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -818,7 +786,7 @@ func TestParseEntryTitleWithWhitespaces(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -848,7 +816,7 @@ func TestParseEntryWithEnclosures(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -896,7 +864,7 @@ func TestParseEntryWithEmptyEnclosureURL(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -933,7 +901,7 @@ func TestParseEntryWithFeedBurnerEnclosures(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -974,7 +942,7 @@ func TestParseEntryWithRelativeURL(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1000,7 +968,7 @@ func TestParseEntryWithCommentsURL(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1025,7 +993,7 @@ func TestParseEntryWithInvalidCommentsURL(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1037,7 +1005,7 @@ func TestParseEntryWithInvalidCommentsURL(t *testing.T) {
func TestParseInvalidXml(t *testing.T) {
data := `garbage`
_, err := Parse("https://example.org/", bytes.NewBufferString(data))
_, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err == nil {
t.Error("Parse should returns an error")
}
@ -1052,7 +1020,7 @@ func TestParseFeedTitleWithHTMLEntity(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1071,7 +1039,7 @@ func TestParseFeedTitleWithUnicodeEntityAndCdata(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1094,7 +1062,7 @@ func TestParseItemTitleWithHTMLEntity(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1117,7 +1085,7 @@ func TestParseItemTitleWithNumericCharacterReference(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1140,7 +1108,7 @@ func TestParseItemTitleWithDoubleEncodedEntities(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1159,7 +1127,7 @@ func TestParseFeedLinkWithInvalidCharacterEntity(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1193,7 +1161,7 @@ func TestParseEntryWithMediaGroup(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1251,7 +1219,7 @@ func TestParseEntryWithMediaContent(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1302,7 +1270,7 @@ func TestParseEntryWithMediaPeerLink(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1354,7 +1322,7 @@ func TestEntryDescriptionFromItunesSummary(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1385,7 +1353,7 @@ func TestEntryDescriptionFromItunesSubtitle(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1419,7 +1387,7 @@ func TestEntryDescriptionFromGooglePlayDescription(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1435,23 +1403,53 @@ func TestEntryDescriptionFromGooglePlayDescription(t *testing.T) {
}
}
func TestParseEntryWithCategoryAndInnerHTML(t *testing.T) {
func TestParseEntryWithRSSDescriptionAndMediaDescription(t *testing.T) {
data := `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>Podcast Example</title>
<link>http://www.example.com/index.html</link>
<item>
<title>Entry Title</title>
<link>http://www.example.com/entries/1</link>
<description>Entry Description</description>
<media:description type="plain">Media Description</media:description>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
expected := "Entry Description"
result := feed.Entries[0].Content
if expected != result {
t.Errorf(`Unexpected description, got %q instead of %q`, result, expected)
}
}
func TestParseFeedWithCategories(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>Example</title>
<link>https://example.org/</link>
<atom:link href="https://example.org/rss" type="application/rss+xml" rel="self"></atom:link>
<category>Category 1</category>
<category><![CDATA[Category 2]]></category>
<item>
<title>Test</title>
<link>https://example.org/item</link>
<category>Category 1</category>
<category>Category 2</category>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1460,32 +1458,104 @@ func TestParseEntryWithCategoryAndInnerHTML(t *testing.T) {
t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
}
expected := "Category 2"
result := feed.Entries[0].Tags[1]
if result != expected {
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
expected := []string{"Category 1", "Category 2"}
result := feed.Entries[0].Tags
for i, tag := range result {
if tag != expected[i] {
t.Errorf("Incorrect tag, got: %q", tag)
}
}
}
func TestParseEntryWithCategoryAndCDATA(t *testing.T) {
func TestParseEntryWithCategories(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>Example</title>
<link>https://example.org/</link>
<atom:link href="https://example.org/rss" type="application/rss+xml" rel="self"></atom:link>
<category>Category 3</category>
<item>
<title>Test</title>
<link>https://example.org/item</link>
<author>
by <![CDATA[Foo Bar]]>
</author>
<category>Sample Category</category>
<category>Category 1</category>
<category><![CDATA[Category 2]]></category>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries[0].Tags) != 3 {
t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
}
expected := []string{"Category 1", "Category 2", "Category 3"}
result := feed.Entries[0].Tags
for i, tag := range result {
if tag != expected[i] {
t.Errorf("Incorrect tag, got: %q", tag)
}
}
}
func TestParseFeedWithItunesCategories(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
<channel>
<title>Example</title>
<link>https://example.org/</link>
<itunes:category text="Society &amp; Culture">
<itunes:category text="Documentary" />
</itunes:category>
<itunes:category text="Health">
<itunes:category text="Mental Health" />
</itunes:category>
<item>
<title>Test</title>
<link>https://example.org/item</link>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries[0].Tags) != 4 {
t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
}
expected := []string{"Society & Culture", "Documentary", "Health", "Mental Health"}
result := feed.Entries[0].Tags
for i, tag := range result {
if tag != expected[i] {
t.Errorf("Incorrect tag, got: %q", tag)
}
}
}
func TestParseFeedWithGooglePlayCategory(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:gplay="http://www.google.com/schemas/play-podcasts/1.0" version="2.0">
<channel>
<title>Example</title>
<link>https://example.org/</link>
<gplay:category text="Art"></gplay:category>
<item>
<title>Test</title>
<link>https://example.org/item</link>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1494,10 +1564,13 @@ func TestParseEntryWithCategoryAndCDATA(t *testing.T) {
t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
}
expected := "Sample Category"
result := feed.Entries[0].Tags[0]
if result != expected {
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
expected := []string{"Art"}
result := feed.Entries[0].Tags
for i, tag := range result {
if tag != expected[i] {
t.Errorf("Incorrect tag, got: %q", tag)
}
}
}
@ -1515,7 +1588,7 @@ func TestParseFeedWithTTLField(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
@ -1539,7 +1612,7 @@ func TestParseFeedWithIncorrectTTLValue(t *testing.T) {
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}

View File

@ -12,70 +12,6 @@ import (
var ErrInvalidDurationFormat = errors.New("rss: invalid duration format")
// PodcastFeedElement represents iTunes and GooglePlay feed XML elements.
// Specs:
// - https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS
// - https://developers.google.com/search/reference/podcast/rss-feed
type PodcastFeedElement struct {
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd channel>author"`
Subtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd channel>subtitle"`
Summary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd channel>summary"`
PodcastOwner PodcastOwner `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd channel>owner"`
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 channel>author"`
}
// PodcastEntryElement represents iTunes and GooglePlay entry XML elements.
type PodcastEntryElement struct {
Subtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle"`
Summary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
Duration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
}
// PodcastOwner represents contact information for the podcast owner.
type PodcastOwner struct {
Name string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd name"`
Email string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd email"`
}
// Image represents podcast artwork.
type Image struct {
URL string `xml:"href,attr"`
}
// PodcastAuthor returns the author of the podcast.
func (e *PodcastFeedElement) PodcastAuthor() string {
author := ""
switch {
case e.ItunesAuthor != "":
author = e.ItunesAuthor
case e.GooglePlayAuthor != "":
author = e.GooglePlayAuthor
case e.PodcastOwner.Name != "":
author = e.PodcastOwner.Name
case e.PodcastOwner.Email != "":
author = e.PodcastOwner.Email
}
return strings.TrimSpace(author)
}
// PodcastDescription returns the description of the podcast.
func (e *PodcastEntryElement) PodcastDescription() string {
description := ""
switch {
case e.GooglePlayDescription != "":
description = e.GooglePlayDescription
case e.Summary != "":
description = e.Summary
case e.Subtitle != "":
description = e.Subtitle
}
return strings.TrimSpace(description)
}
// normalizeDuration returns the duration tag value as a number of minutes
func normalizeDuration(rawDuration string) (int, error) {
var sumSeconds int

View File

@ -16,26 +16,35 @@ import (
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/dublincore"
"miniflux.app/v2/internal/reader/googleplay"
"miniflux.app/v2/internal/reader/itunes"
"miniflux.app/v2/internal/reader/media"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
// Specs: https://cyber.harvard.edu/rss/rss.html
// Specs: https://www.rssboard.org/rss-specification
type rssFeed struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Title string `xml:"channel>title"`
Links []rssLink `xml:"channel>link"`
ImageURL string `xml:"channel>image>url"`
Language string `xml:"channel>language"`
Description string `xml:"channel>description"`
PubDate string `xml:"channel>pubDate"`
ManagingEditor string `xml:"channel>managingEditor"`
Webmaster string `xml:"channel>webMaster"`
TimeToLive rssTTL `xml:"channel>ttl"`
Items []rssItem `xml:"channel>item"`
PodcastFeedElement
XMLName xml.Name `xml:"rss"`
Version string `xml:"rss version,attr"`
Channel rssChannel `xml:"rss channel"`
}
type rssChannel struct {
Categories []string `xml:"rss category"`
Title string `xml:"rss title"`
Link string `xml:"rss link"`
ImageURL string `xml:"rss image>url"`
Language string `xml:"rss language"`
Description string `xml:"rss description"`
PubDate string `xml:"rss pubDate"`
ManagingEditor string `xml:"rss managingEditor"`
Webmaster string `xml:"rss webMaster"`
TimeToLive rssTTL `xml:"rss ttl"`
Items []rssItem `xml:"rss item"`
AtomLinks
itunes.ItunesFeedElement
googleplay.GooglePlayFeedElement
}
type rssTTL struct {
@ -72,15 +81,15 @@ func (r *rssFeed) Transform(baseURL string) *model.Feed {
feed.FeedURL = feedURL
}
feed.Title = html.UnescapeString(strings.TrimSpace(r.Title))
feed.Title = html.UnescapeString(strings.TrimSpace(r.Channel.Title))
if feed.Title == "" {
feed.Title = feed.SiteURL
}
feed.IconURL = strings.TrimSpace(r.ImageURL)
feed.TTL = r.TimeToLive.Value()
feed.IconURL = strings.TrimSpace(r.Channel.ImageURL)
feed.TTL = r.Channel.TimeToLive.Value()
for _, item := range r.Items {
for _, item := range r.Channel.Items {
entry := item.Transform()
if entry.Author == "" {
entry.Author = r.feedAuthor()
@ -103,6 +112,13 @@ func (r *rssFeed) Transform(baseURL string) *model.Feed {
entry.Title = entry.URL
}
entry.Tags = append(entry.Tags, r.Channel.Categories...)
entry.Tags = append(entry.Tags, r.Channel.GetItunesCategories()...)
if r.Channel.GooglePlayCategory.Text != "" {
entry.Tags = append(entry.Tags, r.Channel.GooglePlayCategory.Text)
}
feed.Entries = append(feed.Entries, entry)
}
@ -110,32 +126,31 @@ func (r *rssFeed) Transform(baseURL string) *model.Feed {
}
func (r *rssFeed) siteURL() string {
for _, element := range r.Links {
if element.XMLName.Space == "" {
return strings.TrimSpace(element.Data)
}
}
return ""
return strings.TrimSpace(r.Channel.Link)
}
func (r *rssFeed) feedURL() string {
for _, element := range r.Links {
if element.XMLName.Space == "http://www.w3.org/2005/Atom" {
return strings.TrimSpace(element.Href)
for _, atomLink := range r.Channel.AtomLinks.Links {
if atomLink.Rel == "self" {
return strings.TrimSpace(atomLink.URL)
}
}
return ""
}
func (r rssFeed) feedAuthor() string {
author := r.PodcastAuthor()
var author string
switch {
case r.ManagingEditor != "":
author = r.ManagingEditor
case r.Webmaster != "":
author = r.Webmaster
case r.Channel.ItunesAuthor != "":
author = r.Channel.ItunesAuthor
case r.Channel.GooglePlayAuthor != "":
author = r.Channel.GooglePlayAuthor
case r.Channel.ItunesOwner.String() != "":
author = r.Channel.ItunesOwner.String()
case r.Channel.ManagingEditor != "":
author = r.Channel.ManagingEditor
case r.Channel.Webmaster != "":
author = r.Channel.Webmaster
}
return sanitizer.StripTags(strings.TrimSpace(author))
}
@ -146,27 +161,7 @@ type rssGUID struct {
IsPermaLink string `xml:"isPermaLink,attr"`
}
type rssLink struct {
XMLName xml.Name
Data string `xml:",chardata"`
Href string `xml:"href,attr"`
Rel string `xml:"rel,attr"`
}
type rssCommentLink struct {
XMLName xml.Name
Data string `xml:",chardata"`
}
type rssAuthor struct {
XMLName xml.Name
Data string `xml:",chardata"`
Name string `xml:"name"`
Email string `xml:"email"`
Inner string `xml:",innerxml"`
}
type rssTitle struct {
XMLName xml.Name
Data string `xml:",chardata"`
Inner string `xml:",innerxml"`
@ -178,12 +173,6 @@ type rssEnclosure struct {
Length string `xml:"length,attr"`
}
type rssCategory struct {
XMLName xml.Name
Data string `xml:",chardata"`
Inner string `xml:",innerxml"`
}
func (enclosure *rssEnclosure) Size() int64 {
if enclosure.Length == "" {
return 0
@ -193,19 +182,22 @@ func (enclosure *rssEnclosure) Size() int64 {
}
type rssItem struct {
GUID rssGUID `xml:"guid"`
Title []rssTitle `xml:"title"`
Links []rssLink `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
Authors []rssAuthor `xml:"author"`
CommentLinks []rssCommentLink `xml:"comments"`
EnclosureLinks []rssEnclosure `xml:"enclosure"`
Categories []rssCategory `xml:"category"`
GUID rssGUID `xml:"rss guid"`
Title string `xml:"rss title"`
Link string `xml:"rss link"`
Description string `xml:"rss description"`
PubDate string `xml:"rss pubDate"`
Author rssAuthor `xml:"rss author"`
Comments string `xml:"rss comments"`
EnclosureLinks []rssEnclosure `xml:"rss enclosure"`
Categories []string `xml:"rss category"`
dublincore.DublinCoreItemElement
FeedBurnerElement
PodcastEntryElement
media.Element
AtomAuthor
AtomLinks
itunes.ItunesItemElement
googleplay.GooglePlayItemElement
}
func (r *rssItem) Transform() *model.Entry {
@ -218,8 +210,8 @@ func (r *rssItem) Transform() *model.Entry {
entry.Content = r.entryContent()
entry.Title = r.entryTitle()
entry.Enclosures = r.entryEnclosures()
entry.Tags = r.entryCategories()
if duration, err := normalizeDuration(r.Duration); err == nil {
entry.Tags = r.Categories
if duration, err := normalizeDuration(r.ItunesDuration); err == nil {
entry.ReadingTime = duration
}
@ -250,34 +242,24 @@ func (r *rssItem) entryDate() time.Time {
}
func (r *rssItem) entryAuthor() string {
author := ""
var author string
for _, rssAuthor := range r.Authors {
switch rssAuthor.XMLName.Space {
case "http://www.itunes.com/dtds/podcast-1.0.dtd", "http://www.google.com/schemas/play-podcasts/1.0":
author = rssAuthor.Data
case "http://www.w3.org/2005/Atom":
if rssAuthor.Name != "" {
author = rssAuthor.Name
} else if rssAuthor.Email != "" {
author = rssAuthor.Email
}
default:
if rssAuthor.Name != "" {
author = rssAuthor.Name
} else if strings.Contains(rssAuthor.Inner, "<![CDATA[") {
author = rssAuthor.Data
} else {
author = rssAuthor.Inner
}
}
switch {
case r.GooglePlayAuthor != "":
author = r.GooglePlayAuthor
case r.ItunesAuthor != "":
author = r.ItunesAuthor
case r.DublinCoreCreator != "":
author = r.DublinCoreCreator
case r.AtomAuthor.String() != "":
author = r.AtomAuthor.String()
case strings.Contains(r.Author.Inner, "<![CDATA["):
author = r.Author.Data
default:
author = r.Author.Inner
}
if author == "" {
author = r.GetSanitizedCreator()
}
return sanitizer.StripTags(strings.TrimSpace(author))
return strings.TrimSpace(sanitizer.StripTags(author))
}
func (r *rssItem) entryHash() string {
@ -291,28 +273,23 @@ func (r *rssItem) entryHash() string {
}
func (r *rssItem) entryTitle() string {
var title string
title := r.Title
for _, rssTitle := range r.Title {
switch rssTitle.XMLName.Space {
case "http://search.yahoo.com/mrss/":
// Ignore title in media namespace
case "http://purl.org/dc/elements/1.1/":
title = rssTitle.Data
default:
title = rssTitle.Data
}
if title != "" {
break
}
if r.DublinCoreTitle != "" {
title = r.DublinCoreTitle
}
return html.UnescapeString(strings.TrimSpace(title))
}
func (r *rssItem) entryContent() string {
for _, value := range []string{r.DublinCoreContent, r.Description, r.PodcastDescription()} {
for _, value := range []string{
r.DublinCoreContent,
r.Description,
r.GooglePlayDescription,
r.ItunesSummary,
r.ItunesSubtitle,
} {
if value != "" {
return value
}
@ -321,17 +298,15 @@ func (r *rssItem) entryContent() string {
}
func (r *rssItem) entryURL() string {
if r.FeedBurnerLink != "" {
return r.FeedBurnerLink
for _, link := range []string{r.FeedBurnerLink, r.Link} {
if link != "" {
return strings.TrimSpace(link)
}
}
for _, link := range r.Links {
if link.XMLName.Space == "http://www.w3.org/2005/Atom" && link.Href != "" && isValidLinkRelation(link.Rel) {
return strings.TrimSpace(link.Href)
}
if link.Data != "" {
return strings.TrimSpace(link.Data)
for _, atomLink := range r.AtomLinks.Links {
if atomLink.URL != "" && (strings.EqualFold(atomLink.Rel, "alternate") || atomLink.Rel == "") {
return strings.TrimSpace(atomLink.URL)
}
}
@ -410,43 +385,11 @@ func (r *rssItem) entryEnclosures() model.EnclosureList {
return enclosures
}
func (r *rssItem) entryCategories() []string {
categoryList := make([]string, 0)
for _, rssCategory := range r.Categories {
if strings.Contains(rssCategory.Inner, "<![CDATA[") {
categoryList = append(categoryList, strings.TrimSpace(rssCategory.Data))
} else {
categoryList = append(categoryList, strings.TrimSpace(rssCategory.Inner))
}
}
return categoryList
}
func (r *rssItem) entryCommentsURL() string {
for _, commentLink := range r.CommentLinks {
if commentLink.XMLName.Space == "" {
commentsURL := strings.TrimSpace(commentLink.Data)
// The comments URL is supposed to be absolute (some feeds publishes incorrect comments URL)
// See https://cyber.harvard.edu/rss/rss.html#ltcommentsgtSubelementOfLtitemgt
if urllib.IsAbsoluteURL(commentsURL) {
return commentsURL
}
}
commentsURL := strings.TrimSpace(r.Comments)
if commentsURL != "" && urllib.IsAbsoluteURL(commentsURL) {
return commentsURL
}
return ""
}
func isValidLinkRelation(rel string) bool {
switch rel {
case "", "alternate", "enclosure", "related", "self", "via":
return true
default:
if strings.HasPrefix(rel, "http") {
return true
}
return false
}
}

View File

@ -4,10 +4,10 @@
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
import (
"bytes"
"fmt"
"io"
"regexp"
"slices"
"strconv"
"strings"
@ -18,17 +18,73 @@ import (
)
var (
youtubeEmbedRegex = regexp.MustCompile(`//www\.youtube\.com/embed/(.*)`)
youtubeEmbedRegex = regexp.MustCompile(`//(?:www\.)?youtube\.com/embed/(.+)$`)
tagAllowList = map[string][]string{
"a": {"href", "title", "id"},
"abbr": {"title"},
"acronym": {"title"},
"audio": {"src"},
"blockquote": {},
"br": {},
"caption": {},
"cite": {},
"code": {},
"dd": {"id"},
"del": {},
"dfn": {},
"dl": {"id"},
"dt": {"id"},
"em": {},
"figcaption": {},
"figure": {},
"h1": {"id"},
"h2": {"id"},
"h3": {"id"},
"h4": {"id"},
"h5": {"id"},
"h6": {"id"},
"iframe": {"width", "height", "frameborder", "src", "allowfullscreen"},
"img": {"alt", "title", "src", "srcset", "sizes", "width", "height"},
"ins": {},
"kbd": {},
"li": {"id"},
"ol": {"id"},
"p": {},
"picture": {},
"pre": {},
"q": {"cite"},
"rp": {},
"rt": {},
"rtc": {},
"ruby": {},
"s": {},
"samp": {},
"source": {"src", "type", "srcset", "sizes", "media"},
"strong": {},
"sub": {},
"sup": {"id"},
"table": {},
"td": {"rowspan", "colspan"},
"tfooter": {},
"th": {"rowspan", "colspan"},
"thead": {},
"time": {"datetime"},
"tr": {},
"ul": {"id"},
"var": {},
"video": {"poster", "height", "width", "src"},
"wbr": {},
}
)
// Sanitize returns safe HTML.
func Sanitize(baseURL, input string) string {
var buffer bytes.Buffer
var buffer strings.Builder
var tagStack []string
var parentTag string
blacklistedTagDepth := 0
tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
tokenizer := html.NewTokenizer(strings.NewReader(input))
for {
if tokenizer.Next() == html.ErrorToken {
err := tokenizer.Err()
@ -57,7 +113,10 @@ func Sanitize(baseURL, input string) string {
tagName := token.DataAtom.String()
parentTag = tagName
if !isPixelTracker(tagName, token.Attr) && isValidTag(tagName) {
if isPixelTracker(tagName, token.Attr) {
continue
}
if isValidTag(tagName) {
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
if hasRequiredAttributes(tagName, attrNames) {
@ -74,16 +133,18 @@ func Sanitize(baseURL, input string) string {
}
case html.EndTagToken:
tagName := token.DataAtom.String()
if isValidTag(tagName) && inList(tagName, tagStack) {
buffer.WriteString(fmt.Sprintf("</%s>", tagName))
if isValidTag(tagName) && slices.Contains(tagStack, tagName) {
buffer.WriteString("</" + tagName + ">")
} else if isBlockedTag(tagName) {
blacklistedTagDepth--
}
case html.SelfClosingTagToken:
tagName := token.DataAtom.String()
if !isPixelTracker(tagName, token.Attr) && isValidTag(tagName) {
if isPixelTracker(tagName, token.Attr) {
continue
}
if isValidTag(tagName) {
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
if hasRequiredAttributes(tagName, attrNames) {
if len(attrNames) > 0 {
buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
@ -130,11 +191,10 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
if isExternalResourceAttribute(attribute.Key) {
if tagName == "iframe" {
if isValidIframeSource(baseURL, attribute.Val) {
value = rewriteIframeURL(attribute.Val)
} else {
if !isValidIframeSource(baseURL, attribute.Val) {
continue
}
value = rewriteIframeURL(attribute.Val)
} else if tagName == "img" && attribute.Key == "src" && isValidDataAttribute(attribute.Val) {
value = attribute.Val
} else if isAnchor("a", attribute) {
@ -153,7 +213,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
}
attrNames = append(attrNames, attribute.Key)
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)))
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s=%q`, attribute.Key, html.EscapeString(value)))
}
if !isAnchorLink {
@ -183,24 +243,16 @@ func getExtraAttributes(tagName string) ([]string, []string) {
}
func isValidTag(tagName string) bool {
for element := range getTagAllowList() {
if tagName == element {
return true
}
if _, ok := tagAllowList[tagName]; ok {
return true
}
return false
}
func isValidAttribute(tagName, attributeName string) bool {
for element, attributes := range getTagAllowList() {
if tagName == element {
if inList(attributeName, attributes) {
return true
}
}
if attributes, ok := tagAllowList[tagName]; ok {
return slices.Contains(attributes, attributeName)
}
return false
}
@ -214,45 +266,41 @@ func isExternalResourceAttribute(attribute string) bool {
}
func isPixelTracker(tagName string, attributes []html.Attribute) bool {
if tagName == "img" {
hasHeight := false
hasWidth := false
if tagName != "img" {
return false
}
hasHeight := false
hasWidth := false
for _, attribute := range attributes {
if attribute.Key == "height" && attribute.Val == "1" {
for _, attribute := range attributes {
if attribute.Val == "1" {
if attribute.Key == "height" {
hasHeight = true
}
if attribute.Key == "width" && attribute.Val == "1" {
} else if attribute.Key == "width" {
hasWidth = true
}
}
return hasHeight && hasWidth
}
return false
return hasHeight && hasWidth
}
func hasRequiredAttributes(tagName string, attributes []string) bool {
elements := make(map[string][]string)
elements["a"] = []string{"href"}
elements["iframe"] = []string{"src"}
elements["img"] = []string{"src"}
elements["source"] = []string{"src", "srcset"}
elements := map[string][]string{
"a": {"href"},
"iframe": {"src"},
"img": {"src"},
"source": {"src", "srcset"},
}
for element, attrs := range elements {
if tagName == element {
for _, attribute := range attributes {
for _, attr := range attrs {
if attr == attribute {
return true
}
}
if attrs, ok := elements[tagName]; ok {
for _, attribute := range attributes {
if slices.Contains(attrs, attribute) {
return true
}
return false
}
return false
}
return true
@ -303,13 +351,9 @@ func hasValidURIScheme(src string) bool {
"hack://", // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB
}
for _, prefix := range whitelist {
if strings.HasPrefix(src, prefix) {
return true
}
}
return false
return slices.ContainsFunc(whitelist, func(prefix string) bool {
return strings.HasPrefix(src, prefix)
})
}
func isBlockedResource(src string) bool {
@ -322,125 +366,38 @@ func isBlockedResource(src string) bool {
"feeds.feedburner.com",
}
for _, element := range blacklist {
if strings.Contains(src, element) {
return true
}
}
return false
return slices.ContainsFunc(blacklist, func(element string) bool {
return strings.Contains(src, element)
})
}
func isValidIframeSource(baseURL, src string) bool {
whitelist := []string{
"//www.youtube.com",
"http://www.youtube.com",
"https://www.youtube.com",
"https://www.youtube-nocookie.com",
"http://player.vimeo.com",
"https://player.vimeo.com",
"http://www.dailymotion.com",
"https://www.dailymotion.com",
"http://vk.com",
"https://vk.com",
"http://soundcloud.com",
"https://soundcloud.com",
"http://w.soundcloud.com",
"https://w.soundcloud.com",
"http://bandcamp.com",
"https://bandcamp.com",
"https://cdn.embedly.com",
"https://player.bilibili.com",
"https://player.twitch.tv",
"bandcamp.com",
"cdn.embedly.com",
"player.bilibili.com",
"player.twitch.tv",
"player.vimeo.com",
"soundcloud.com",
"vk.com",
"w.soundcloud.com",
"dailymotion.com",
"youtube-nocookie.com",
"youtube.com",
}
domain := urllib.Domain(src)
// allow iframe from same origin
if urllib.Domain(baseURL) == urllib.Domain(src) {
if urllib.Domain(baseURL) == domain {
return true
}
// allow iframe from custom invidious instance
if config.Opts != nil && config.Opts.InvidiousInstance() == urllib.Domain(src) {
if config.Opts != nil && config.Opts.InvidiousInstance() == domain {
return true
}
for _, prefix := range whitelist {
if strings.HasPrefix(src, prefix) {
return true
}
}
return false
}
func getTagAllowList() map[string][]string {
whitelist := make(map[string][]string)
whitelist["img"] = []string{"alt", "title", "src", "srcset", "sizes", "width", "height"}
whitelist["picture"] = []string{}
whitelist["audio"] = []string{"src"}
whitelist["video"] = []string{"poster", "height", "width", "src"}
whitelist["source"] = []string{"src", "type", "srcset", "sizes", "media"}
whitelist["dt"] = []string{"id"}
whitelist["dd"] = []string{"id"}
whitelist["dl"] = []string{"id"}
whitelist["table"] = []string{}
whitelist["caption"] = []string{}
whitelist["thead"] = []string{}
whitelist["tfooter"] = []string{}
whitelist["tr"] = []string{}
whitelist["td"] = []string{"rowspan", "colspan"}
whitelist["th"] = []string{"rowspan", "colspan"}
whitelist["h1"] = []string{"id"}
whitelist["h2"] = []string{"id"}
whitelist["h3"] = []string{"id"}
whitelist["h4"] = []string{"id"}
whitelist["h5"] = []string{"id"}
whitelist["h6"] = []string{"id"}
whitelist["strong"] = []string{}
whitelist["em"] = []string{}
whitelist["code"] = []string{}
whitelist["pre"] = []string{}
whitelist["blockquote"] = []string{}
whitelist["q"] = []string{"cite"}
whitelist["p"] = []string{}
whitelist["ul"] = []string{"id"}
whitelist["li"] = []string{"id"}
whitelist["ol"] = []string{"id"}
whitelist["br"] = []string{}
whitelist["del"] = []string{}
whitelist["a"] = []string{"href", "title", "id"}
whitelist["figure"] = []string{}
whitelist["figcaption"] = []string{}
whitelist["cite"] = []string{}
whitelist["time"] = []string{"datetime"}
whitelist["abbr"] = []string{"title"}
whitelist["acronym"] = []string{"title"}
whitelist["wbr"] = []string{}
whitelist["dfn"] = []string{}
whitelist["sub"] = []string{}
whitelist["sup"] = []string{"id"}
whitelist["var"] = []string{}
whitelist["samp"] = []string{}
whitelist["s"] = []string{}
whitelist["del"] = []string{}
whitelist["ins"] = []string{}
whitelist["kbd"] = []string{}
whitelist["rp"] = []string{}
whitelist["rt"] = []string{}
whitelist["rtc"] = []string{}
whitelist["ruby"] = []string{}
whitelist["iframe"] = []string{"width", "height", "frameborder", "src", "allowfullscreen"}
return whitelist
}
func inList(needle string, haystack []string) bool {
for _, element := range haystack {
if element == needle {
return true
}
}
return false
return slices.Contains(whitelist, strings.TrimPrefix(domain, "www."))
}
func rewriteIframeURL(link string) string {
@ -459,13 +416,7 @@ func isBlockedTag(tagName string) bool {
"style",
}
for _, element := range blacklist {
if element == tagName {
return true
}
}
return false
return slices.Contains(blacklist, tagName)
}
func sanitizeSrcsetAttr(baseURL, value string) string {
@ -493,13 +444,9 @@ func isValidDataAttribute(value string) bool {
"data:image/gif",
"data:image/webp",
}
for _, prefix := range dataAttributeAllowList {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
return slices.ContainsFunc(dataAttributeAllowList, func(prefix string) bool {
return strings.HasPrefix(value, prefix)
})
}
func isAnchor(tagName string, attribute html.Attribute) bool {

View File

@ -16,6 +16,25 @@ func TestMain(m *testing.M) {
os.Exit(exitCode)
}
func BenchmarkSanitize(b *testing.B) {
var testCases = map[string][]string{
"miniflux_github.html": {"https://github.com/miniflux/v2", ""},
"miniflux_wikipedia.html": {"https://fr.wikipedia.org/wiki/Miniflux", ""},
}
for filename := range testCases {
data, err := os.ReadFile("testdata/" + filename)
if err != nil {
b.Fatalf(`Unable to read file %q: %v`, filename, err)
}
testCases[filename][1] = string(data)
}
for range b.N {
for _, v := range testCases {
Sanitize(v[0], v[1])
}
}
}
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)

View File

@ -11,6 +11,7 @@ import (
)
// StripTags removes all HTML/XML tags from the input string.
// This function must *only* be used for cosmetic purposes, not to prevent code injections like XSS.
func StripTags(input string) string {
tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
var buffer bytes.Buffer

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,807 @@
<!DOCTYPE html>
<html class="client-nojs vector-feature-language-in-header-enabled vector-feature-language-in-main-page-header-disabled vector-feature-sticky-header-disabled vector-feature-page-tools-pinned-disabled vector-feature-toc-pinned-clientpref-1 vector-feature-main-menu-pinned-disabled vector-feature-limited-width-clientpref-1 vector-feature-limited-width-content-enabled vector-feature-custom-font-size-clientpref-0 vector-feature-client-preferences-disabled vector-feature-client-prefs-pinned-disabled vector-feature-night-mode-disabled skin-night-mode-clientpref-0 vector-toc-available" lang="fr" dir="ltr">
<head>
<meta charset="UTF-8">
<title>Miniflux — Wikipédia</title>
<script>(function(){var className="client-js vector-feature-language-in-header-enabled vector-feature-language-in-main-page-header-disabled vector-feature-sticky-header-disabled vector-feature-page-tools-pinned-disabled vector-feature-toc-pinned-clientpref-1 vector-feature-main-menu-pinned-disabled vector-feature-limited-width-clientpref-1 vector-feature-limited-width-content-enabled vector-feature-custom-font-size-clientpref-0 vector-feature-client-preferences-disabled vector-feature-client-prefs-pinned-disabled vector-feature-night-mode-disabled skin-night-mode-clientpref-0 vector-toc-available";var cookie=document.cookie.match(/(?:^|; )frwikimwclientpreferences=([^;]+)/);if(cookie){cookie[1].split('%2C').forEach(function(pref){className=className.replace(new RegExp('(^| )'+pref.replace(/-clientpref-\w+$|[^\w-]+/g,'')+'-clientpref-\\w+( |$)'),'$1'+pref+'$2');});}document.documentElement.className=className;}());RLCONF={"wgBreakFrames":false,"wgSeparatorTransformTable":[",\t."," \t,"],
"wgDigitTransformTable":["",""],"wgDefaultDateFormat":"dmy","wgMonthNames":["","janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"],"wgRequestId":"22e3aa19-1dce-40a9-bbd5-d250c14d2223","wgCanonicalNamespace":"","wgCanonicalSpecialPageName":false,"wgNamespaceNumber":0,"wgPageName":"Miniflux","wgTitle":"Miniflux","wgCurRevisionId":204322562,"wgRevisionId":204322562,"wgArticleId":7063156,"wgIsArticle":true,"wgIsRedirect":false,"wgAction":"view","wgUserName":null,"wgUserGroups":["*"],"wgCategories":["Wikipédia:ébauche Internet","Article manquant de références depuis mars 2014","Article manquant de références/Liste complète","Page utilisant P348","Page utilisant P1324","Page utilisant P277","Logiciel catégorisé automatiquement par langage d'écriture","Article utilisant une Infobox","Article contenant un appel à traduction en anglais","Portail:Logiciels libres/Articles liés","Portail:Logiciel/Articles liés",
"Portail:Informatique/Articles liés","Logiciel écrit en Go","Agrégateur","Application web"],"wgPageViewLanguage":"fr","wgPageContentLanguage":"fr","wgPageContentModel":"wikitext","wgRelevantPageName":"Miniflux","wgRelevantArticleId":7063156,"wgIsProbablyEditable":true,"wgRelevantPageIsProbablyEditable":true,"wgRestrictionEdit":[],"wgRestrictionMove":[],"wgNoticeProject":"wikipedia","wgMediaViewerOnClick":true,"wgMediaViewerEnabledByDefault":true,"wgPopupsFlags":4,"wgVisualEditor":{"pageLanguageCode":"fr","pageLanguageDir":"ltr","pageVariantFallbacks":"fr"},"wgMFDisplayWikibaseDescriptions":{"search":true,"watchlist":true,"tagline":true,"nearby":true},"wgWMESchemaEditAttemptStepOversample":false,"wgWMEPageLength":2000,"wgULSCurrentAutonym":"français","wgCentralAuthMobileDomain":false,"wgEditSubmitButtonLabelPublish":true,"wgULSPosition":"interlanguage","wgULSisCompactLinksEnabled":false,"wgVector2022LanguageInHeader":true,"wgULSisLanguageSelectorEmpty":false,"wgWikibaseItemId":
"Q16664605","wgCheckUserClientHintsHeadersJsApi":["architecture","bitness","brands","fullVersionList","mobile","model","platform","platformVersion"],"GEHomepageSuggestedEditsEnableTopics":true,"wgGETopicsMatchModeEnabled":false,"wgGEStructuredTaskRejectionReasonTextInputEnabled":false,"wgGELevelingUpEnabledForUser":false};RLSTATE={"skins.vector.user.styles":"ready","ext.globalCssJs.user.styles":"ready","site.styles":"ready","user.styles":"ready","skins.vector.user":"ready","ext.globalCssJs.user":"ready","user":"ready","user.options":"loading","ext.cite.styles":"ready","codex-search-styles":"ready","skins.vector.styles":"ready","skins.vector.icons":"ready","ext.visualEditor.desktopArticleTarget.noscript":"ready","ext.uls.interlanguage":"ready","wikibase.client.init":"ready","ext.wikimediaBadges":"ready"};RLPAGEMODULES=["ext.cite.ux-enhancements","site","mediawiki.page.ready","skins.vector.js","ext.centralNotice.geoIP","ext.centralNotice.startUp","ext.gadget.ArchiveLinks",
"ext.gadget.Wdsearch","ext.urlShortener.toolbar","ext.centralauth.centralautologin","mmv.head","mmv.bootstrap.autostart","ext.popups","ext.visualEditor.desktopArticleTarget.init","ext.visualEditor.targetLoader","ext.echo.centralauth","ext.eventLogging","ext.wikimediaEvents","ext.navigationTiming","ext.uls.interface","ext.cx.eventlogging.campaigns","ext.cx.uls.quick.actions","wikibase.client.vector-2022","ext.checkUser.clientHints","ext.quicksurveys.init","ext.growthExperiments.SuggestedEditSession"];</script>
<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.impl(function(){return["user.options@12s5i",function($,jQuery,require,module){mw.user.tokens.set({"patrolToken":"+\\","watchToken":"+\\","csrfToken":"+\\"});
}];});});</script>
<link rel="stylesheet" href="/w/load.php?lang=fr&amp;modules=codex-search-styles%7Cext.cite.styles%7Cext.uls.interlanguage%7Cext.visualEditor.desktopArticleTarget.noscript%7Cext.wikimediaBadges%7Cskins.vector.icons%2Cstyles%7Cwikibase.client.init&amp;only=styles&amp;skin=vector-2022">
<script async="" src="/w/load.php?lang=fr&amp;modules=startup&amp;only=scripts&amp;raw=1&amp;skin=vector-2022"></script>
<meta name="ResourceLoaderDynamicStyles" content="">
<link rel="stylesheet" href="/w/load.php?lang=fr&amp;modules=site.styles&amp;only=styles&amp;skin=vector-2022">
<meta name="generator" content="MediaWiki 1.42.0-wmf.20">
<meta name="referrer" content="origin">
<meta name="referrer" content="origin-when-cross-origin">
<meta name="robots" content="max-image-preview:standard">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=1000">
<meta property="og:title" content="Miniflux — Wikipédia">
<meta property="og:type" content="website">
<link rel="preconnect" href="//upload.wikimedia.org">
<link rel="alternate" media="only screen and (max-width: 720px)" href="//fr.m.wikipedia.org/wiki/Miniflux">
<link rel="alternate" type="application/x-wiki" title="Modifier" href="/w/index.php?title=Miniflux&amp;action=edit">
<link rel="apple-touch-icon" href="/static/apple-touch/wikipedia.png">
<link rel="icon" href="/static/favicon/wikipedia.ico">
<link rel="search" type="application/opensearchdescription+xml" href="/w/opensearch_desc.php" title="Wikipédia (fr)">
<link rel="EditURI" type="application/rsd+xml" href="//fr.wikipedia.org/w/api.php?action=rsd">
<link rel="canonical" href="https://fr.wikipedia.org/wiki/Miniflux">
<link rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/deed.fr">
<link rel="alternate" type="application/atom+xml" title="Flux Atom de Wikipédia" href="/w/index.php?title=Sp%C3%A9cial:Modifications_r%C3%A9centes&amp;feed=atom">
<link rel="dns-prefetch" href="//meta.wikimedia.org" />
<link rel="dns-prefetch" href="//login.wikimedia.org">
</head>
<body class="skin-vector skin-vector-search-vue mediawiki ltr sitedir-ltr mw-hide-empty-elt ns-0 ns-subject mw-editable page-Miniflux rootpage-Miniflux skin-vector-2022 action-view"><a class="mw-jump-link" href="#bodyContent">Aller au contenu</a>
<div class="vector-header-container">
<header class="vector-header mw-header">
<div class="vector-header-start">
<nav class="vector-main-menu-landmark" aria-label="Site" role="navigation">
<div id="vector-main-menu-dropdown" class="vector-dropdown vector-main-menu-dropdown vector-button-flush-left vector-button-flush-right" >
<input type="checkbox" id="vector-main-menu-dropdown-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-vector-main-menu-dropdown" class="vector-dropdown-checkbox " aria-label="Menu principal" >
<label id="vector-main-menu-dropdown-label" for="vector-main-menu-dropdown-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only " aria-hidden="true" ><span class="vector-icon mw-ui-icon-menu mw-ui-icon-wikimedia-menu"></span>
<span class="vector-dropdown-label-text">Menu principal</span>
</label>
<div class="vector-dropdown-content">
<div id="vector-main-menu-unpinned-container" class="vector-unpinned-container">
<div id="vector-main-menu" class="vector-main-menu vector-pinnable-element">
<div
class="vector-pinnable-header vector-main-menu-pinnable-header vector-pinnable-header-unpinned"
data-feature-name="main-menu-pinned"
data-pinnable-element-id="vector-main-menu"
data-pinned-container-id="vector-main-menu-pinned-container"
data-unpinned-container-id="vector-main-menu-unpinned-container"
>
<div class="vector-pinnable-header-label">Menu principal</div>
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-pin-button" data-event-name="pinnable-header.vector-main-menu.pin">déplacer vers la barre latérale</button>
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-unpin-button" data-event-name="pinnable-header.vector-main-menu.unpin">masquer</button>
</div>
<div id="p-navigation" class="vector-menu mw-portlet mw-portlet-navigation" >
<div class="vector-menu-heading">
Navigation
</div>
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
<li id="n-mainpage-description" class="mw-list-item"><a href="/wiki/Wikip%C3%A9dia:Accueil_principal" title="Accueil général [z]" accesskey="z"><span>Accueil</span></a></li><li id="n-thema" class="mw-list-item"><a href="/wiki/Portail:Accueil"><span>Portails thématiques</span></a></li><li id="n-randompage" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Page_au_hasard" title="Affiche un article au hasard [x]" accesskey="x"><span>Article au hasard</span></a></li><li id="n-contact" class="mw-list-item"><a href="/wiki/Wikip%C3%A9dia:Contact"><span>Contact</span></a></li>
</ul>
</div>
</div>
<div id="p-Contribuer" class="vector-menu mw-portlet mw-portlet-Contribuer" >
<div class="vector-menu-heading">
Contribuer
</div>
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
<li id="n-aboutwp" class="mw-list-item"><a href="/wiki/Aide:D%C3%A9buter"><span>Débuter sur Wikipédia</span></a></li><li id="n-help" class="mw-list-item"><a href="/wiki/Aide:Accueil" title="Accès à laide"><span>Aide</span></a></li><li id="n-portal" class="mw-list-item"><a href="/wiki/Wikip%C3%A9dia:Accueil_de_la_communaut%C3%A9" title="À propos du projet, ce que vous pouvez faire, où trouver les informations"><span>Communauté</span></a></li><li id="n-recentchanges" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Modifications_r%C3%A9centes" title="Liste des modifications récentes sur le wiki [r]" accesskey="r"><span>Modifications récentes</span></a></li><li id="n-sitesupport" class="mw-list-item"><a href="//donate.wikimedia.org/wiki/Special:FundraiserRedirector?utm_source=donate&amp;utm_medium=sidebar&amp;utm_campaign=C13_fr.wikipedia.org&amp;uselang=fr" title="Soutenez-nous"><span>Faire un don</span></a></li>
</ul>
</div>
</div>
<div class="vector-main-menu-action vector-main-menu-action-lang-alert vector-main-menu-action-lang-alert-empty">
<div class="vector-main-menu-action-item">
<div class="vector-main-menu-action-heading vector-menu-heading">Langues</div>
<div class="vector-main-menu-action-content vector-menu-content">
<div class="mw-message-box cdx-message cdx-message--block mw-message-box-notice cdx-message--notice vector-language-sidebar-alert"><span class="cdx-message__icon"></span><div class="cdx-message__content">Sur cette version linguistique de Wikipédia, les liens interlangues sont placés en haut à droite du titre de larticle.<br /><a href="#p-lang-btn">Aller en haut</a>.</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</nav>
<a href="/wiki/Wikip%C3%A9dia:Accueil_principal" class="mw-logo">
<img class="mw-logo-icon" src="/static/images/icons/wikipedia.png" alt="" aria-hidden="true" height="50" width="50">
<span class="mw-logo-container">
<img class="mw-logo-wordmark" alt="Wikipédia" src="/static/images/mobile/copyright/wikipedia-wordmark-fr.svg" style="width: 7.5em; height: 1.125em;">
<img class="mw-logo-tagline" alt="l&#039;encyclopédie libre" src="/static/images/mobile/copyright/wikipedia-tagline-fr.svg" width="120" height="13" style="width: 7.5em; height: 0.8125em;">
</span>
</a>
</div>
<div class="vector-header-end">
<div id="p-search" role="search" class="vector-search-box-vue vector-search-box-collapses vector-search-box-show-thumbnail vector-search-box-auto-expand-width vector-search-box">
<a href="/wiki/Sp%C3%A9cial:Recherche" class="cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only search-toggle" id="" title="Rechercher sur Wikipédia [f]" accesskey="f"><span class="vector-icon mw-ui-icon-search mw-ui-icon-wikimedia-search"></span>
<span>Rechercher</span>
</a>
<div class="vector-typeahead-search-container">
<div class="cdx-typeahead-search cdx-typeahead-search--show-thumbnail cdx-typeahead-search--auto-expand-width">
<form action="/w/index.php" id="searchform" class="cdx-search-input cdx-search-input--has-end-button">
<div id="simpleSearch" class="cdx-search-input__input-wrapper" data-search-loc="header-moved">
<div class="cdx-text-input cdx-text-input--has-start-icon">
<input
class="cdx-text-input__input"
type="search" name="search" placeholder="Rechercher sur Wikipédia" aria-label="Rechercher sur Wikipédia" autocapitalize="sentences" title="Rechercher sur Wikipédia [f]" accesskey="f" id="searchInput"
>
<span class="cdx-text-input__icon cdx-text-input__start-icon"></span>
</div>
<input type="hidden" name="title" value="Spécial:Recherche">
</div>
<button class="cdx-button cdx-search-input__end-button">Rechercher</button>
</form>
</div>
</div>
</div>
<nav class="vector-user-links vector-user-links-wide" aria-label="Outils personnels" role="navigation" >
<div class="vector-user-links-main">
<div id="p-vector-user-menu-preferences" class="vector-menu mw-portlet emptyPortlet" >
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
</ul>
</div>
</div>
<div id="p-vector-user-menu-userpage" class="vector-menu mw-portlet emptyPortlet" >
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
</ul>
</div>
</div>
<nav class="vector-client-prefs-landmark" aria-label="Apparence">
</nav>
<div id="p-vector-user-menu-notifications" class="vector-menu mw-portlet emptyPortlet" >
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
</ul>
</div>
</div>
<div id="p-vector-user-menu-overflow" class="vector-menu mw-portlet" >
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
<li id="pt-createaccount-2" class="user-links-collapsible-item mw-list-item user-links-collapsible-item"><a data-mw="interface" href="/w/index.php?title=Sp%C3%A9cial:Cr%C3%A9er_un_compte&amp;returnto=Miniflux" title="Nous vous encourageons à créer un compte utilisateur et vous connecter; ce nest cependant pas obligatoire." class=""><span>Créer un compte</span></a>
</li>
<li id="pt-login-2" class="user-links-collapsible-item mw-list-item user-links-collapsible-item"><a data-mw="interface" href="/w/index.php?title=Sp%C3%A9cial:Connexion&amp;returnto=Miniflux" title="Nous vous encourageons à vous connecter; ce nest cependant pas obligatoire. [o]" accesskey="o" class=""><span>Se connecter</span></a>
</li>
</ul>
</div>
</div>
</div>
<div id="vector-user-links-dropdown" class="vector-dropdown vector-user-menu vector-button-flush-right vector-user-menu-logged-out" title="Plus doptions" >
<input type="checkbox" id="vector-user-links-dropdown-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-vector-user-links-dropdown" class="vector-dropdown-checkbox " aria-label="Outils personnels" >
<label id="vector-user-links-dropdown-label" for="vector-user-links-dropdown-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only " aria-hidden="true" ><span class="vector-icon mw-ui-icon-ellipsis mw-ui-icon-wikimedia-ellipsis"></span>
<span class="vector-dropdown-label-text">Outils personnels</span>
</label>
<div class="vector-dropdown-content">
<div id="p-personal" class="vector-menu mw-portlet mw-portlet-personal user-links-collapsible-item" title="Menu utilisateur" >
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
<li id="pt-createaccount" class="user-links-collapsible-item mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:Cr%C3%A9er_un_compte&amp;returnto=Miniflux" title="Nous vous encourageons à créer un compte utilisateur et vous connecter; ce nest cependant pas obligatoire."><span class="vector-icon mw-ui-icon-userAdd mw-ui-icon-wikimedia-userAdd"></span> <span>Créer un compte</span></a></li><li id="pt-login" class="user-links-collapsible-item mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:Connexion&amp;returnto=Miniflux" title="Nous vous encourageons à vous connecter; ce nest cependant pas obligatoire. [o]" accesskey="o"><span class="vector-icon mw-ui-icon-logIn mw-ui-icon-wikimedia-logIn"></span> <span>Se connecter</span></a></li>
</ul>
</div>
</div>
<div id="p-user-menu-anon-editor" class="vector-menu mw-portlet mw-portlet-user-menu-anon-editor" >
<div class="vector-menu-heading">
Pages pour les contributeurs déconnectés <a href="/wiki/Aide:Introduction" aria-label="En savoir plus sur la contribution"><span>en savoir plus</span></a>
</div>
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
<li id="pt-anoncontribs" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Mes_contributions" title="Une liste des modifications effectuées depuis cette adresse IP [y]" accesskey="y"><span>Contributions</span></a></li><li id="pt-anontalk" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Mes_discussions" title="La page de discussion pour les contributions depuis cette adresse IP [n]" accesskey="n"><span>Discussion</span></a></li>
</ul>
</div>
</div>
</div>
</div>
</nav>
</div>
</header>
</div>
<div class="mw-page-container">
<div class="mw-page-container-inner">
<div class="vector-sitenotice-container">
<div id="siteNotice"><!-- CentralNotice --></div>
</div>
<div class="vector-column-start">
<div class="vector-main-menu-container">
<div id="mw-navigation">
<nav id="mw-panel" class="vector-main-menu-landmark" aria-label="Site" role="navigation">
<div id="vector-main-menu-pinned-container" class="vector-pinned-container">
</div>
</nav>
</div>
</div>
<div class="vector-sticky-pinned-container">
<nav id="mw-panel-toc" role="navigation" aria-label="Sommaire" data-event-name="ui.sidebar-toc" class="mw-table-of-contents-container vector-toc-landmark">
<div id="vector-toc-pinned-container" class="vector-pinned-container">
<div id="vector-toc" class="vector-toc vector-pinnable-element">
<div
class="vector-pinnable-header vector-toc-pinnable-header vector-pinnable-header-pinned"
data-feature-name="toc-pinned"
data-pinnable-element-id="vector-toc"
>
<h2 class="vector-pinnable-header-label">Sommaire</h2>
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-pin-button" data-event-name="pinnable-header.vector-toc.pin">déplacer vers la barre latérale</button>
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-unpin-button" data-event-name="pinnable-header.vector-toc.unpin">masquer</button>
</div>
<ul class="vector-toc-contents" id="mw-panel-toc-list">
<li id="toc-mw-content-text"
class="vector-toc-list-item vector-toc-level-1">
<a href="#" class="vector-toc-link">
<div class="vector-toc-text">Début</div>
</a>
</li>
<li id="toc-Caractéristiques"
class="vector-toc-list-item vector-toc-level-1 vector-toc-list-item-expanded">
<a class="vector-toc-link" href="#Caractéristiques">
<div class="vector-toc-text">
<span class="vector-toc-numb">1</span>Caractéristiques</div>
</a>
<ul id="toc-Caractéristiques-sublist" class="vector-toc-list">
</ul>
</li>
<li id="toc-Liens_externes"
class="vector-toc-list-item vector-toc-level-1 vector-toc-list-item-expanded">
<a class="vector-toc-link" href="#Liens_externes">
<div class="vector-toc-text">
<span class="vector-toc-numb">2</span>Liens externes</div>
</a>
<ul id="toc-Liens_externes-sublist" class="vector-toc-list">
</ul>
</li>
<li id="toc-Notes_et_références"
class="vector-toc-list-item vector-toc-level-1 vector-toc-list-item-expanded">
<a class="vector-toc-link" href="#Notes_et_références">
<div class="vector-toc-text">
<span class="vector-toc-numb">3</span>Notes et références</div>
</a>
<ul id="toc-Notes_et_références-sublist" class="vector-toc-list">
</ul>
</li>
</ul>
</div>
</div>
</nav>
</div>
</div>
<div class="mw-content-container">
<main id="content" class="mw-body" role="main">
<header class="mw-body-header vector-page-titlebar">
<nav role="navigation" aria-label="Sommaire" class="vector-toc-landmark">
<div id="vector-page-titlebar-toc" class="vector-dropdown vector-page-titlebar-toc vector-button-flush-left" >
<input type="checkbox" id="vector-page-titlebar-toc-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-vector-page-titlebar-toc" class="vector-dropdown-checkbox " aria-label="Basculer la table des matières" >
<label id="vector-page-titlebar-toc-label" for="vector-page-titlebar-toc-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only " aria-hidden="true" ><span class="vector-icon mw-ui-icon-listBullet mw-ui-icon-wikimedia-listBullet"></span>
<span class="vector-dropdown-label-text">Basculer la table des matières</span>
</label>
<div class="vector-dropdown-content">
<div id="vector-page-titlebar-toc-unpinned-container" class="vector-unpinned-container">
</div>
</div>
</div>
</nav>
<h1 id="firstHeading" class="firstHeading mw-first-heading"><span class="mw-page-title-main">Miniflux</span></h1>
<div id="p-lang-btn" class="vector-dropdown mw-portlet mw-portlet-lang" >
<input type="checkbox" id="p-lang-btn-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-p-lang-btn" class="vector-dropdown-checkbox mw-interlanguage-selector" aria-label="Cet article nexiste que dans cette langue. Ajouter larticle pour dautres langues." >
<label id="p-lang-btn-label" for="p-lang-btn-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--action-progressive mw-portlet-lang-heading-0" aria-hidden="true" ><span class="vector-icon mw-ui-icon-language-progressive mw-ui-icon-wikimedia-language-progressive"></span>
<span class="vector-dropdown-label-text">Ajouter des langues</span>
</label>
<div class="vector-dropdown-content">
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
</ul>
<div class="after-portlet after-portlet-lang"><span class="uls-after-portlet-link"></span><span class="wb-langlinks-add wb-langlinks-link"><a href="https://www.wikidata.org/wiki/Special:EntityPage/Q16664605#sitelinks-wikipedia" title="Ajouter des liens interlangues" class="wbc-editpage">Ajouter des liens</a></span></div>
</div>
</div>
</div>
</header>
<div class="vector-page-toolbar">
<div class="vector-page-toolbar-container">
<div id="left-navigation">
<nav aria-label="Espaces de noms">
<div id="p-associated-pages" class="vector-menu vector-menu-tabs mw-portlet mw-portlet-associated-pages" >
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
<li id="ca-nstab-main" class="selected vector-tab-noicon mw-list-item"><a href="/wiki/Miniflux" title="Voir le contenu de la page [c]" accesskey="c"><span>Article</span></a></li><li id="ca-talk" class="new vector-tab-noicon mw-list-item"><a href="/w/index.php?title=Discussion:Miniflux&amp;action=edit&amp;redlink=1" rel="discussion" title="Discussion au sujet de cette page de contenu (page inexistante) [t]" accesskey="t"><span>Discussion</span></a></li>
</ul>
</div>
</div>
<div id="p-variants" class="vector-dropdown emptyPortlet" >
<input type="checkbox" id="p-variants-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-p-variants" class="vector-dropdown-checkbox " aria-label="Modifier la variante de langue" >
<label id="p-variants-label" for="p-variants-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet" aria-hidden="true" ><span class="vector-dropdown-label-text">français</span>
</label>
<div class="vector-dropdown-content">
<div id="p-variants" class="vector-menu mw-portlet mw-portlet-variants emptyPortlet" >
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
</ul>
</div>
</div>
</div>
</div>
</nav>
</div>
<div id="right-navigation" class="vector-collapsible">
<nav aria-label="Affichages">
<div id="p-views" class="vector-menu vector-menu-tabs mw-portlet mw-portlet-views" >
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
<li id="ca-view" class="selected vector-tab-noicon mw-list-item"><a href="/wiki/Miniflux"><span>Lire</span></a></li><li id="ca-ve-edit" class="vector-tab-noicon mw-list-item"><a href="/w/index.php?title=Miniflux&amp;veaction=edit" title="Modifier cette page [v]" accesskey="v"><span>Modifier</span></a></li><li id="ca-edit" class="collapsible vector-tab-noicon mw-list-item"><a href="/w/index.php?title=Miniflux&amp;action=edit" title="Modifier le wikicode de cette page [e]" accesskey="e"><span>Modifier le code</span></a></li><li id="ca-history" class="vector-tab-noicon mw-list-item"><a href="/w/index.php?title=Miniflux&amp;action=history" title="Historique des versions de cette page [h]" accesskey="h"><span>Voir lhistorique</span></a></li>
</ul>
</div>
</div>
</nav>
<nav class="vector-page-tools-landmark" aria-label="Outils de la page">
<div id="vector-page-tools-dropdown" class="vector-dropdown vector-page-tools-dropdown" >
<input type="checkbox" id="vector-page-tools-dropdown-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-vector-page-tools-dropdown" class="vector-dropdown-checkbox " aria-label="Outils" >
<label id="vector-page-tools-dropdown-label" for="vector-page-tools-dropdown-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet" aria-hidden="true" ><span class="vector-dropdown-label-text">Outils</span>
</label>
<div class="vector-dropdown-content">
<div id="vector-page-tools-unpinned-container" class="vector-unpinned-container">
<div id="vector-page-tools" class="vector-page-tools vector-pinnable-element">
<div
class="vector-pinnable-header vector-page-tools-pinnable-header vector-pinnable-header-unpinned"
data-feature-name="page-tools-pinned"
data-pinnable-element-id="vector-page-tools"
data-pinned-container-id="vector-page-tools-pinned-container"
data-unpinned-container-id="vector-page-tools-unpinned-container"
>
<div class="vector-pinnable-header-label">Outils</div>
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-pin-button" data-event-name="pinnable-header.vector-page-tools.pin">déplacer vers la barre latérale</button>
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-unpin-button" data-event-name="pinnable-header.vector-page-tools.unpin">masquer</button>
</div>
<div id="p-cactions" class="vector-menu mw-portlet mw-portlet-cactions emptyPortlet vector-has-collapsible-items" title="Plus doptions" >
<div class="vector-menu-heading">
Actions
</div>
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
<li id="ca-more-view" class="selected vector-more-collapsible-item mw-list-item"><a href="/wiki/Miniflux"><span>Lire</span></a></li><li id="ca-more-ve-edit" class="vector-more-collapsible-item mw-list-item"><a href="/w/index.php?title=Miniflux&amp;veaction=edit" title="Modifier cette page [v]" accesskey="v"><span>Modifier</span></a></li><li id="ca-more-edit" class="collapsible vector-more-collapsible-item mw-list-item"><a href="/w/index.php?title=Miniflux&amp;action=edit" title="Modifier le wikicode de cette page [e]" accesskey="e"><span>Modifier le code</span></a></li><li id="ca-more-history" class="vector-more-collapsible-item mw-list-item"><a href="/w/index.php?title=Miniflux&amp;action=history"><span>Voir lhistorique</span></a></li>
</ul>
</div>
</div>
<div id="p-tb" class="vector-menu mw-portlet mw-portlet-tb" >
<div class="vector-menu-heading">
Général
</div>
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
<li id="t-whatlinkshere" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Pages_li%C3%A9es/Miniflux" title="Liste des pages liées qui pointent sur celle-ci [j]" accesskey="j"><span>Pages liées</span></a></li><li id="t-recentchangeslinked" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Suivi_des_liens/Miniflux" rel="nofollow" title="Liste des modifications récentes des pages appelées par celle-ci [k]" accesskey="k"><span>Suivi des pages liées</span></a></li><li id="t-upload" class="mw-list-item"><a href="/wiki/Aide:Importer_un_fichier" title="Téléverser des fichiers [u]" accesskey="u"><span>Téléverser un fichier</span></a></li><li id="t-specialpages" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Pages_sp%C3%A9ciales" title="Liste de toutes les pages spéciales [q]" accesskey="q"><span>Pages spéciales</span></a></li><li id="t-permalink" class="mw-list-item"><a href="/w/index.php?title=Miniflux&amp;oldid=204322562" title="Adresse permanente de cette version de cette page"><span>Lien permanent</span></a></li><li id="t-info" class="mw-list-item"><a href="/w/index.php?title=Miniflux&amp;action=info" title="Davantage dinformations sur cette page"><span>Informations sur la page</span></a></li><li id="t-cite" class="mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:Citer&amp;page=Miniflux&amp;id=204322562&amp;wpFormIdentifier=titleform" title="Informations sur la manière de citer cette page"><span>Citer cette page</span></a></li><li id="t-urlshortener" class="mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:UrlShortener&amp;url=https%3A%2F%2Ffr.wikipedia.org%2Fwiki%2FMiniflux"><span>Obtenir l'URL raccourcie</span></a></li><li id="t-urlshortener-qrcode" class="mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:QrCode&amp;url=https%3A%2F%2Ffr.wikipedia.org%2Fwiki%2FMiniflux"><span>Télécharger le code QR</span></a></li><li id="t-wikibase" class="mw-list-item"><a href="https://www.wikidata.org/wiki/Special:EntityPage/Q16664605" title="Lien vers lélément dans le dépôt de données connecté [g]" accesskey="g"><span>Élément Wikidata</span></a></li>
</ul>
</div>
</div>
<div id="p-coll-print_export" class="vector-menu mw-portlet mw-portlet-coll-print_export" >
<div class="vector-menu-heading">
Imprimer/exporter
</div>
<div class="vector-menu-content">
<ul class="vector-menu-content-list">
<li id="coll-create_a_book" class="mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:Livre&amp;bookcmd=book_creator&amp;referer=Miniflux"><span>Créer un livre</span></a></li><li id="coll-download-as-rl" class="mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:DownloadAsPdf&amp;page=Miniflux&amp;action=show-download-screen"><span>Télécharger comme PDF</span></a></li><li id="t-print" class="mw-list-item"><a href="/w/index.php?title=Miniflux&amp;printable=yes" title="Version imprimable de cette page [p]" accesskey="p"><span>Version imprimable</span></a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</nav>
</div>
</div>
</div>
<div class="vector-column-end">
<div class="vector-sticky-pinned-container">
<nav class="vector-page-tools-landmark" aria-label="Outils de la page">
<div id="vector-page-tools-pinned-container" class="vector-pinned-container">
</div>
</nav>
<nav class="vector-client-prefs-landmark" aria-label="Apparence">
</nav>
</div>
</div>
<div id="bodyContent" class="vector-body" aria-labelledby="firstHeading" data-mw-ve-target-container>
<div class="vector-body-before-content">
<div class="mw-indicators">
</div>
<div id="siteSub" class="noprint">Un article de Wikipédia, l&#039;encyclopédie libre.</div>
</div>
<div id="contentSub"><div id="mw-content-subtitle"></div></div>
<div id="mw-content-text" class="mw-body-content"><div class="mw-content-ltr mw-parser-output" lang="fr" dir="ltr"><div class="bandeau-container metadata bandeau-article bandeau-niveau-ebauche"><div class="bandeau-cell bandeau-icone" style="display:table-cell;padding-right:0.5em"><span class="noviewer" typeof="mw:File"><a href="/wiki/Fichier:Circle-icons-email.svg" class="mw-file-description"><img alt="" src="//upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Circle-icons-email.svg/35px-Circle-icons-email.svg.png" decoding="async" width="35" height="35" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Circle-icons-email.svg/53px-Circle-icons-email.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Circle-icons-email.svg/70px-Circle-icons-email.svg.png 2x" data-file-width="512" data-file-height="512" /></a></span></div><div class="bandeau-cell" style="display:table-cell;padding-right:0.5em">
<p><strong class="bandeau-titre">Cet article est une <a href="/wiki/Aide:%C3%89bauche" title="Aide:Ébauche">ébauche</a> concernant <a href="/wiki/Internet" title="Internet">Internet</a>.</strong>
</p><p>Vous pouvez partager vos connaissances en laméliorant (<b><a href="/wiki/Aide:Comment_modifier_une_page" title="Aide:Comment modifier une page">comment&#160;?</a></b>) selon les recommandations des <a href="/wiki/Projet:Accueil" title="Projet:Accueil">projets correspondants</a>.
</p>
</div></div>
<div class="bandeau-container metadata bandeau-article bandeau-niveau-modere"><figure class="mw-halign-right noviewer" typeof="mw:File"><a href="/wiki/Mod%C3%A8le:Sources_secondaires" title="Si ce bandeau n&#39;est plus pertinent, retirez-le. Cliquez ici pour en savoir plus."><img alt="Si ce bandeau n&#39;est plus pertinent, retirez-le. Cliquez ici pour en savoir plus." src="//upload.wikimedia.org/wikipedia/commons/thumb/3/38/Info_Simple.svg/12px-Info_Simple.svg.png" decoding="async" width="12" height="12" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/3/38/Info_Simple.svg/18px-Info_Simple.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/3/38/Info_Simple.svg/24px-Info_Simple.svg.png 2x" data-file-width="512" data-file-height="512" /></a><figcaption>Si ce bandeau n'est plus pertinent, retirez-le. Cliquez ici pour en savoir plus.</figcaption></figure><div class="bandeau-cell bandeau-icone" style="display:table-cell;padding-right:0.5em"><span class="noviewer" typeof="mw:File"><a href="/wiki/Fichier:2017-fr.wp-orange-source.svg" class="mw-file-description"><img alt="" src="//upload.wikimedia.org/wikipedia/commons/thumb/a/a1/2017-fr.wp-orange-source.svg/45px-2017-fr.wp-orange-source.svg.png" decoding="async" width="45" height="45" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/a/a1/2017-fr.wp-orange-source.svg/68px-2017-fr.wp-orange-source.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/a/a1/2017-fr.wp-orange-source.svg/90px-2017-fr.wp-orange-source.svg.png 2x" data-file-width="512" data-file-height="512" /></a></span></div><div class="bandeau-cell" style="display:table-cell;padding-right:0.5em">
<p><strong class="bandeau-titre">Cet article ne s'appuie pas, ou pas assez, sur des sources <a href="/wiki/Wikip%C3%A9dia:Sources_primaires,_secondaires_et_tertiaires" title="Wikipédia:Sources primaires, secondaires et tertiaires">secondaires ou tertiaires</a></strong> <small>(<time class="nowrap" datetime="2014-03" data-sort-value="2014-03">mars 2014</time>).</small>
</p><p>Pour améliorer la <a href="/wiki/Wikip%C3%A9dia:V%C3%A9rifiabilit%C3%A9" title="Wikipédia:Vérifiabilité">vérifiabilité</a> de l'article ainsi que <a href="/wiki/Wikip%C3%A9dia:Ce_que_Wikip%C3%A9dia_n%27est_pas#Un_annuaire_ou_une_base_de_données" title="Wikipédia:Ce que Wikipédia n&#39;est pas">son intérêt encyclopédique</a>, il est nécessaire, quand des <a href="/wiki/Wikip%C3%A9dia:Sources_primaires,_secondaires_et_tertiaires" title="Wikipédia:Sources primaires, secondaires et tertiaires">sources primaires</a> sont citées, de les associer à des analyses faites par des sources secondaires.
</p>
</div></div>
<div class="infobox_v3 noarchive">
<div class="entete icon informatique" style="color: #000000;"><style data-mw-deduplicate="TemplateStyles:r188801372">.mw-parser-output .entete.informatique{background-image:url("//upload.wikimedia.org/wikipedia/commons/a/ae/Picto-infoboxinfo.png")}</style>
<div>Miniflux</div>
</div>
<p class="mw-empty-elt">
</p>
<table><caption style="color:#000000;">Informations</caption>
<tbody><tr>
<th scope="row"><a href="/wiki/D%C3%A9veloppeur" title="Développeur">Développé par</a></th>
<td>
Frédéric Guillot</td>
</tr>
<tr>
<th scope="row"> <a href="/wiki/Version_d%27un_logiciel" title="Version d&#39;un logiciel">Dernière version</a>
</th>
<td>
<span class="wd_p348">2.1.0 (<time class="nowrap" datetime="2024-02-17" data-sort-value="2024-02-17">17 février 2024</time>)<sup id="cite_ref-wikidata-f2992d0f89b91ee9578634940004e13779ead67d_1-0" class="reference"><a href="#cite_note-wikidata-f2992d0f89b91ee9578634940004e13779ead67d-1"><span class="cite_crochet">[</span>1<span class="cite_crochet">]</span></a></sup><span class="noprint wikidata-linkback"><span class="mw-valign-baseline noviewer" typeof="mw:File"><a href="https://www.wikidata.org/wiki/Q16664605?uselang=fr#P348" title="Voir et modifier les données sur Wikidata"><img alt="Voir et modifier les données sur Wikidata" src="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/10px-Blue_pencil.svg.png" decoding="async" width="10" height="10" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/15px-Blue_pencil.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/20px-Blue_pencil.svg.png 2x" data-file-width="600" data-file-height="600" /></a></span></span></span></td>
</tr>
<tr>
<th scope="row"><a href="/wiki/D%C3%A9p%C3%B4t_(informatique)" title="Dépôt (informatique)">Dépôt</a></th>
<td>
<span class="wd_p1324"><a rel="nofollow" class="external text" href="https://github.com/miniflux/miniflux">github.com/miniflux/miniflux</a><span class="noprint wikidata-linkback"><span class="mw-valign-baseline noviewer" typeof="mw:File"><a href="https://www.wikidata.org/wiki/Q16664605?uselang=fr#P1324" title="Voir et modifier les données sur Wikidata"><img alt="Voir et modifier les données sur Wikidata" src="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/10px-Blue_pencil.svg.png" decoding="async" width="10" height="10" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/15px-Blue_pencil.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/20px-Blue_pencil.svg.png 2x" data-file-width="600" data-file-height="600" /></a></span></span></span></td>
</tr>
<tr>
<th scope="row"> <a href="/wiki/Langage_de_programmation" title="Langage de programmation">Écrit en</a>
</th>
<td>
<span class="wd_p277"><a href="/wiki/Go_(langage)" title="Go (langage)">Go</a><span class="noprint wikidata-linkback"><span class="mw-valign-baseline noviewer" typeof="mw:File"><a href="https://www.wikidata.org/wiki/Q16664605?uselang=fr#P277" title="Voir et modifier les données sur Wikidata"><img alt="Voir et modifier les données sur Wikidata" src="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/10px-Blue_pencil.svg.png" decoding="async" width="10" height="10" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/15px-Blue_pencil.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/20px-Blue_pencil.svg.png 2x" data-file-width="600" data-file-height="600" /></a></span></span></span></td>
</tr>
<tr>
<th scope="row"><a href="/wiki/Plate-forme_(informatique)" title="Plate-forme (informatique)">Environnement</a></th>
<td>
<a href="/wiki/Logiciel_multiplate-forme" class="mw-redirect" title="Logiciel multiplate-forme">multiplateforme</a></td>
</tr>
<tr>
<th scope="row"><a href="/wiki/Internationalisation_(informatique)" title="Internationalisation (informatique)">Langues</a></th>
<td>
<a href="/wiki/Multilingue" class="mw-redirect" title="Multilingue">Multilingue</a></td>
</tr>
<tr>
<th scope="row"> Type
</th>
<td>
<a href="/wiki/RSS" title="RSS">agrégateur de RSS</a></td>
</tr>
<tr>
<th scope="row"><a href="/wiki/Licence_de_logiciel" title="Licence de logiciel">Licence</a></th>
<td>
<a href="/wiki/AGPL" class="mw-redirect" title="AGPL">Licence AGPL</a></td>
</tr>
<tr>
<th scope="row"><a href="/wiki/Site_web" title="Site web">Site web</a></th>
<td>
<a rel="nofollow" class="external text" href="http://miniflux.net/">miniflux.net</a></td>
</tr>
</tbody></table>
<p class="mw-empty-elt">
</p>
<p class="navbar bordered noprint" style=""><span class="plainlinks"><a class="external text" href="https://fr.wikipedia.org/w/index.php?title=Miniflux&amp;veaction=edit&amp;section=0">modifier</a> - <a class="external text" href="https://fr.wikipedia.org/w/index.php?title=Miniflux&amp;action=edit&amp;section=0">modifier le code</a> - <a href="https://www.wikidata.org/wiki/Special:ItemByTitle/frwiki/Miniflux" class="extiw" title="d:Special:ItemByTitle/frwiki/Miniflux">voir Wikidata</a> <a href="/wiki/Aide:Infobox_Wikidata" title="Aide:Infobox Wikidata">(aide)</a></span> <span typeof="mw:File"><a href="/wiki/Mod%C3%A8le:Infobox_Logiciel" title="Consultez la documentation du modèle"><img alt="Consultez la documentation du modèle" src="//upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Gtk-dialog-info.svg/12px-Gtk-dialog-info.svg.png" decoding="async" width="12" height="12" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Gtk-dialog-info.svg/18px-Gtk-dialog-info.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Gtk-dialog-info.svg/24px-Gtk-dialog-info.svg.png 2x" data-file-width="60" data-file-height="60" /></a></span></p></div>
<p><b>Miniflux</b> est un agrégateur de flux <a href="/wiki/RSS" title="RSS">RSS</a> minimaliste. C'est une <a href="/wiki/Application_web" title="Application web">application web</a> <a href="/wiki/Logiciel_libre" title="Logiciel libre">libre</a> diffusé sous licence <a href="/wiki/AGPL" class="mw-redirect" title="AGPL">AGPL</a>.
Ce logiciel est prévu pour être <a href="/wiki/Auto-h%C3%A9bergement_(Internet)" title="Auto-hébergement (Internet)">auto-hébergé</a> sur son propre serveur ou sur un hébergement mutualisé.
</p>
<h2><span id="Caract.C3.A9ristiques"></span><span class="mw-headline" id="Caractéristiques">Caractéristiques</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Miniflux&amp;veaction=edit&amp;section=1" title="Modifier la section: Caractéristiques" class="mw-editsection-visualeditor"><span>modifier</span></a><span class="mw-editsection-divider"> | </span><a href="/w/index.php?title=Miniflux&amp;action=edit&amp;section=1" title="Modifier le code source de la section : Caractéristiques"><span>modifier le code</span></a><span class="mw-editsection-bracket">]</span></span></h2>
<ul><li>Mises à jour&#160;: il est possible de mettre à jour ses abonnements depuis une tâche planifiée (<a href="/wiki/Cron" title="Cron">cronjob</a>) ou depuis l'interface web (<a href="/wiki/Ajax_(informatique)" title="Ajax (informatique)">Ajax</a>). De plus, Miniflux vérifie les en-têtes <a href="/wiki/HTTP" class="mw-redirect" title="HTTP">HTTP</a> et met à jour les flux uniquement lorsque c'est nécessaire.</li>
<li>Réseaux sociaux&#160;: Miniflux ne s'intègre pas avec les réseaux sociaux, il n'y a donc aucune forme de partage, d'envoi par email ou encore de système de favoris.</li>
<li>Publicité&#160;: les publicités dans les abonnements sont supprimées automatiquement ainsi que tout éventuel "<a href="/wiki/Pixel_espion" title="Pixel espion">pixel espion</a>" (image de 1px sur 1px utilisée par les outils de statistiques). De plus, les liens externes ne transmettent pas le <a href="/wiki/R%C3%A9f%C3%A9rent_(informatique)" title="Référent (informatique)">référent</a> (l'<a href="/wiki/Uniform_Resource_Locator" title="Uniform Resource Locator">adresse web</a> d'où l'utilisateur vient).</li>
<li>Données personnelles&#160;: Miniflux est compatible avec le format <a href="/wiki/OPML" class="mw-redirect" title="OPML">OPML</a> qui permet d'importer ou d'exporter sa liste d'abonnements.</li>
<li>Accessibilité&#160;: une fois installé, Miniflux est accessible à la manière d'une <a href="/wiki/Application_web" title="Application web">application web</a> depuis n'importe quel navigateur web et ce même sur les appareils mobiles.</li>
<li><a href="/wiki/Interface_de_programmation" title="Interface de programmation">Interface de programmation</a>: Le logiciel intègre une interface de programmation de sorte à voir créer des scripts afin d'automatiser certaines tâches comme la création d'un utilisateur, de récupérer des statistiques ou encore de récupérer des données concernant son flux ou ses abonnements<sup id="cite_ref-2" class="reference"><a href="#cite_note-2"><span class="cite_crochet">[</span>2<span class="cite_crochet">]</span></a></sup>.</li></ul>
<h2><span class="mw-headline" id="Liens_externes">Liens externes</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Miniflux&amp;veaction=edit&amp;section=2" title="Modifier la section: Liens externes" class="mw-editsection-visualeditor"><span>modifier</span></a><span class="mw-editsection-divider"> | </span><a href="/w/index.php?title=Miniflux&amp;action=edit&amp;section=2" title="Modifier le code source de la section : Liens externes"><span>modifier le code</span></a><span class="mw-editsection-bracket">]</span></span></h2>
<ul><li><abbr class="abbr indicateur-langue" title="Langue : anglais">(en)</abbr> <a rel="nofollow" class="external text" href="http://miniflux.net/">Site officiel en anglais</a></li></ul>
<h2><span id="Notes_et_r.C3.A9f.C3.A9rences"></span><span class="mw-headline" id="Notes_et_références">Notes et références</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Miniflux&amp;veaction=edit&amp;section=3" title="Modifier la section: Notes et références" class="mw-editsection-visualeditor"><span>modifier</span></a><span class="mw-editsection-divider"> | </span><a href="/w/index.php?title=Miniflux&amp;action=edit&amp;section=3" title="Modifier le code source de la section : Notes et références"><span>modifier le code</span></a><span class="mw-editsection-bracket">]</span></span></h2>
<div class="references-small decimal" style=""><div class="mw-references-wrap"><ol class="references">
<li id="cite_note-wikidata-f2992d0f89b91ee9578634940004e13779ead67d-1"><span class="mw-cite-backlink noprint"><a href="#cite_ref-wikidata-f2992d0f89b91ee9578634940004e13779ead67d_1-0"></a> </span><span class="reference-text"><span class="ouvrage">«&#160;<a rel="nofollow" class="external text" href="https://github.com/miniflux/v2/releases/tag/2.1.0"><cite style="font-style:normal;"><span class="lang-en" lang="en">Release 2.1.0</span></cite></a>&#160;», <time class="nowrap" datetime="2024-02-17" data-sort-value="2024-02-17">17 février 2024</time> <small style="line-height:1em;">(consulté le <time class="nowrap" datetime="2024-02-20" data-sort-value="2024-02-20">20 février 2024</time>)</small></span></span>
</li>
<li id="cite_note-2"><span class="mw-cite-backlink noprint"><a href="#cite_ref-2"></a> </span><span class="reference-text"><span class="ouvrage">«&#160;<a rel="nofollow" class="external text" href="https://miniflux.app/docs/api.html"><cite style="font-style:normal;">API Reference - Documentation</cite></a>&#160;», sur <span class="italique">miniflux.app</span> <small style="line-height:1em;">(consulté le <time class="nowrap" datetime="2020-06-26" data-sort-value="2020-06-26">26 juin 2020</time>)</small></span></span>
</li>
</ol></div>
</div>
<div class="navbox-container" style="clear:both;">
<table class="navbox collapsible noprint autocollapse" style="">
<tbody><tr><th class="navbox-title" colspan="3" style=""><div style="float:left; width:6em; text-align:left"><div class="noprint plainlinks nowrap tnavbar" style="background-color:transparent; padding:0; font-size:xx-small; color:#000000;"><a href="/wiki/Mod%C3%A8le:Palette_Agr%C3%A9gateurs" title="Modèle:Palette Agrégateurs"><abbr class="abbr" title="Voir ce modèle.">v</abbr></a>&#160;· <a class="external text" href="https://fr.wikipedia.org/w/index.php?title=Mod%C3%A8le:Palette_Agr%C3%A9gateurs&amp;action=edit"><abbr class="abbr" title="Modifier ce modèle. Merci de prévisualiser avant de sauvegarder.">m</abbr></a></div></div><div style="font-size:110%"><a href="/wiki/Agr%C3%A9gateur" title="Agrégateur">Agrégateurs</a></div></th>
</tr> <tr>
<th class="navbox-group" style=""><a href="/wiki/Client_lourd" title="Client lourd">Clients de bureau</a></th>
<td class="navbox-list" style=""><table class="navbox-subgroup" style="">
<tbody><tr>
<th class="navbox-group" style="width:10px; white-space:nowrap;"><a href="/wiki/Logiciel_libre" title="Logiciel libre">Libre</a></th>
<td class="navbox-list" style=";background:#EDEDFF;"><div class="liste-horizontale">
<ul><li><i><a href="/wiki/Akregator" title="Akregator">Akregator</a></i></li>
<li><span class="description-wikidata" title="&lt;span class=&quot;error&quot;&gt;identifiant wikidata inconnu&lt;/span&gt;"><a href="/w/index.php?title=%27%27FeedReader%27%27&amp;action=edit&amp;redlink=1" class="new" title="&#39;&#39;FeedReader&#39;&#39; (page inexistante)"><i>FeedReader</i></a> <a href="https://www.wikidata.org/wiki/Q50836189" class="extiw" title="d:Q50836189"><span class="indicateur-langue">(<abbr class="abbr" title="Wikidata">d</abbr>)</span></a>&#160;<span typeof="mw:File"><a href="//tools.wmflabs.org/reasonator/?q=Q50836189&amp;lang=fr" title="Voir avec Reasonator"><img alt="Voir avec Reasonator" src="//upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Wikidata-Reasonator_small_logo.svg/12px-Wikidata-Reasonator_small_logo.svg.png" decoding="async" width="12" height="12" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Wikidata-Reasonator_small_logo.svg/18px-Wikidata-Reasonator_small_logo.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Wikidata-Reasonator_small_logo.svg/24px-Wikidata-Reasonator_small_logo.svg.png 2x" data-file-width="500" data-file-height="500" /></a></span></span></li>
<li><i><a href="/wiki/Liferea" title="Liferea">Liferea</a></i></li>
<li><i><a href="/wiki/Mozilla_Thunderbird" title="Mozilla Thunderbird">Mozilla Thunderbird</a></i></li>
<li><i>QuiteRSS</i></li>
<li><i><a href="/wiki/GNOME_Web" title="GNOME Web">Web</a></i></li>
<li><i><a href="/wiki/QBittorrent" title="QBittorrent">QBittorrent</a></i></li>
<li><i>RSSOwl</i></li>
<li><i><a href="/wiki/Zimbra" title="Zimbra">Zimbra</a></i></li></ul>
</div></td>
</tr> <tr>
<th class="navbox-group" style="width:10px; white-space:nowrap;"><a href="/wiki/Logiciel_propri%C3%A9taire" title="Logiciel propriétaire">Propriétaire</a></th>
<td class="navbox-list navbox-even" style=";"><div class="liste-horizontale">
<ul><li><i><a href="/wiki/Microsoft_Outlook" title="Microsoft Outlook">Microsoft Outlook</a></i></li></ul>
</div></td>
</tr>
</tbody></table></td>
<td class="navbox-image" rowspan="2" style="vertical-align:middle;padding-left:7px"><span class="noviewer" typeof="mw:File"><a href="/wiki/Fichier:Feed-icon.svg" class="mw-file-description"><img src="//upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/70px-Feed-icon.svg.png" decoding="async" width="70" height="70" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/105px-Feed-icon.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/140px-Feed-icon.svg.png 2x" data-file-width="128" data-file-height="128" /></a></span></td>
</tr> <tr>
<th class="navbox-group" style="">Basés sur le <a href="/wiki/Web" class="mw-redirect" title="Web">web</a></th>
<td class="navbox-list navbox-even" style=""><table class="navbox-subgroup" style="">
<tbody><tr>
<th class="navbox-group" style="width:10px; white-space:nowrap;"><a href="/wiki/Logiciel_libre" title="Logiciel libre">Libre</a></th>
<td class="navbox-list" style=";background:#EDEDFF;"><div class="liste-horizontale">
<ul><li><i><a href="/w/index.php?title=Feedbin&amp;action=edit&amp;redlink=1" class="new" title="Feedbin (page inexistante)">Feedbin</a>&#160;<a href="https://en.wikipedia.org/wiki/Feedbin" class="extiw" title="en:Feedbin"><span class="indicateur-langue" title="Article en anglais&#160;: «&#160;Feedbin&#160;»">(en)</span></a></i></li>
<li><i><a href="/wiki/FreshRSS" title="FreshRSS">FreshRSS</a></i></li>
<li><i><a href="/wiki/KrISS-feed" title="KrISS-feed">KrISS-feed</a></i></li>
<li><i>Leed</i></li>
<li><i>Selfoss</i></li>
<li><i><a href="/wiki/NewsBlur" title="NewsBlur">NewsBlur</a></i></li>
<li><i>Cartulary</i></li>
<li><i><a class="mw-selflink selflink">Miniflux</a></i></li>
<li><i><a href="/wiki/Tiny_Tiny_RSS" title="Tiny Tiny RSS">Tiny Tiny RSS</a></i></li></ul>
</div></td>
</tr> <tr>
<th class="navbox-group" style="width:10px; white-space:nowrap;"><a href="/wiki/Logiciel_propri%C3%A9taire" title="Logiciel propriétaire">Propriétaire</a></th>
<td class="navbox-list navbox-even" style=";"><div class="liste-horizontale">
<ul><li><i><a href="/wiki/Feedly" title="Feedly">Feedly</a></i></li>
<li><i><a href="/wiki/Inoreader" title="Inoreader">Inoreader</a></i></li>
<li><i><a href="/wiki/Netvibes" title="Netvibes">Netvibes</a></i></li></ul>
</div></td>
</tr>
</tbody></table></td>
</tr> </tbody></table>
</div><p>,
</p><ul id="bandeau-portail" class="bandeau-portail"><li><span class="bandeau-portail-element"><span class="bandeau-portail-icone"><span class="noviewer" typeof="mw:File"><a href="/wiki/Portail:Logiciels_libres" title="Portail des logiciels libres"><img alt="icône décorative" src="//upload.wikimedia.org/wikipedia/commons/thumb/2/22/Heckert_GNU_white.svg/25px-Heckert_GNU_white.svg.png" decoding="async" width="25" height="24" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/2/22/Heckert_GNU_white.svg/37px-Heckert_GNU_white.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/2/22/Heckert_GNU_white.svg/49px-Heckert_GNU_white.svg.png 2x" data-file-width="535" data-file-height="523" /></a></span></span> <span class="bandeau-portail-texte"><a href="/wiki/Portail:Logiciels_libres" title="Portail:Logiciels libres">Portail des logiciels libres</a></span> </span></li> </ul>
<!--
NewPP limit report
Parsed by mwweb.eqiad.canary7c9994f4f86g6bc
Cached time: 20240304111906
Cache expiry: 2592000
Reduced expiry: false
Complications: []
CPU time usage: 0.369 seconds
Real time usage: 0.494 seconds
Preprocessor visited node count: 3432/1000000
Postexpand include size: 58705/2097152 bytes
Template argument size: 16628/2097152 bytes
Highest expansion depth: 21/100
Expensive parser function count: 6/500
Unstrip recursion depth: 0/20
Unstrip postexpand size: 2070/5000000 bytes
Lua time usage: 0.226/10.000 seconds
Lua memory usage: 10407160/52428800 bytes
Number of Wikibase entities loaded: 1/400
-->
<!--
Transclusion expansion time report (%,ms,calls,template)
100.00% 449.099 1 -total
52.56% 236.049 45 Modèle:Wikidata
52.35% 235.089 1 Modèle:Infobox_Logiciel
34.76% 156.108 23 Modèle:Infobox_V3/Tableau_Ligne_mixte
21.42% 96.191 1 Modèle:Ébauche
11.91% 53.472 1 Modèle:Palette
10.43% 46.825 1 Modèle:Palette_Agrégateurs
9.96% 44.723 1 Modèle:Méta_palette_de_navigation
8.15% 36.590 13 Modèle:Infobox_V3/Tableau_Ligne_mixte_Wikidata
8.02% 36.009 2 Modèle:Méta_palette_de_navigation_sous-groupe
-->
<!-- Saved in parser cache with key frwiki:pcache:idhash:7063156-0!canonical and timestamp 20240304111906 and revision id 204322562. Rendering was triggered because: page-view
-->
</div><!--esi <esi:include src="/esitest-fa8a495983347898/content" /> --><noscript><img src="https://login.wikimedia.org/wiki/Special:CentralAutoLogin/start?type=1x1" alt="" width="1" height="1" style="border: none; position: absolute;"></noscript>
<div class="printfooter" data-nosnippet="">Ce document provient de «&#160;<a dir="ltr" href="https://fr.wikipedia.org/w/index.php?title=Miniflux&amp;oldid=204322562">https://fr.wikipedia.org/w/index.php?title=Miniflux&amp;oldid=204322562</a>&#160;».</div></div>
<div id="catlinks" class="catlinks" data-mw="interface"><div id="mw-normal-catlinks" class="mw-normal-catlinks"><a href="/wiki/Cat%C3%A9gorie:Accueil" title="Catégorie:Accueil">Catégories</a>: <ul><li><a href="/wiki/Cat%C3%A9gorie:Logiciel_%C3%A9crit_en_Go" title="Catégorie:Logiciel écrit en Go">Logiciel écrit en Go</a></li><li><a href="/wiki/Cat%C3%A9gorie:Agr%C3%A9gateur" title="Catégorie:Agrégateur">Agrégateur</a></li><li><a href="/wiki/Cat%C3%A9gorie:Application_web" title="Catégorie:Application web">Application web</a></li></ul></div><div id="mw-hidden-catlinks" class="mw-hidden-catlinks mw-hidden-cats-hidden">Catégories cachées: <ul><li><a href="/wiki/Cat%C3%A9gorie:Wikip%C3%A9dia:%C3%A9bauche_Internet" title="Catégorie:Wikipédia:ébauche Internet">Wikipédia:ébauche Internet</a></li><li><a href="/wiki/Cat%C3%A9gorie:Article_manquant_de_r%C3%A9f%C3%A9rences_depuis_mars_2014" title="Catégorie:Article manquant de références depuis mars 2014">Article manquant de références depuis mars 2014</a></li><li><a href="/wiki/Cat%C3%A9gorie:Article_manquant_de_r%C3%A9f%C3%A9rences/Liste_compl%C3%A8te" title="Catégorie:Article manquant de références/Liste complète">Article manquant de références/Liste complète</a></li><li><a href="/wiki/Cat%C3%A9gorie:Page_utilisant_P348" title="Catégorie:Page utilisant P348">Page utilisant P348</a></li><li><a href="/wiki/Cat%C3%A9gorie:Page_utilisant_P1324" title="Catégorie:Page utilisant P1324">Page utilisant P1324</a></li><li><a href="/wiki/Cat%C3%A9gorie:Page_utilisant_P277" title="Catégorie:Page utilisant P277">Page utilisant P277</a></li><li><a href="/wiki/Cat%C3%A9gorie:Logiciel_cat%C3%A9goris%C3%A9_automatiquement_par_langage_d%27%C3%A9criture" title="Catégorie:Logiciel catégorisé automatiquement par langage d&#039;écriture">Logiciel catégorisé automatiquement par langage d'écriture</a></li><li><a href="/wiki/Cat%C3%A9gorie:Article_utilisant_une_Infobox" title="Catégorie:Article utilisant une Infobox">Article utilisant une Infobox</a></li><li><a href="/wiki/Cat%C3%A9gorie:Article_contenant_un_appel_%C3%A0_traduction_en_anglais" title="Catégorie:Article contenant un appel à traduction en anglais">Article contenant un appel à traduction en anglais</a></li><li><a href="/wiki/Cat%C3%A9gorie:Portail:Logiciels_libres/Articles_li%C3%A9s" title="Catégorie:Portail:Logiciels libres/Articles liés">Portail:Logiciels libres/Articles liés</a></li><li><a href="/wiki/Cat%C3%A9gorie:Portail:Logiciel/Articles_li%C3%A9s" title="Catégorie:Portail:Logiciel/Articles liés">Portail:Logiciel/Articles liés</a></li><li><a href="/wiki/Cat%C3%A9gorie:Portail:Informatique/Articles_li%C3%A9s" title="Catégorie:Portail:Informatique/Articles liés">Portail:Informatique/Articles liés</a></li></ul></div></div>
</div>
</main>
</div>
<div class="mw-footer-container">
<footer id="footer" class="mw-footer" role="contentinfo" >
<ul id="footer-info">
<li id="footer-info-lastmod"> La dernière modification de cette page a été faite le 17 mai 2023 à 04:04.</li>
<li id="footer-info-copyright"><span style="white-space: normal"><a class="internal" href="/wiki/Wikip%C3%A9dia:Citation_et_r%C3%A9utilisation_du_contenu_de_Wikip%C3%A9dia" title="Droit d'auteur">Droit d'auteur</a> : les textes sont disponibles sous <a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/deed.fr" title="Licence Creative Commons Attribution - partage dans les mêmes conditions 4.0 international">licence Creative Commons attribution, partage dans les mêmes conditions</a> ; dautres conditions peuvent sappliquer. Voyez les <a href="https://foundation.wikimedia.org/wiki/Policy:Terms_of_Use/fr" title="Conditions dutilisation de la Wikimedia Foundation">conditions dutilisation</a> pour plus de détails, ainsi que les <a class="internal" href="/wiki/Wikip%C3%A9dia:Cr%C3%A9dits_graphiques" title="Droit d'auteur de certaines icônes">crédits graphiques</a>. En cas de réutilisation des textes de cette page, voyez <a class="internal" href="/wiki/Sp%C3%A9cial:Citer/Miniflux" title="Citer ou réutiliser cette page">comment citer les auteurs et mentionner la licence</a>.<br />
Wikipedia® est une marque déposée de la <a href="https://wikimediafoundation.org/" title="Wikimedia Foundation">Wikimedia Foundation, Inc.</a>, organisation de bienfaisance régie par le paragraphe <a class="internal" href="/wiki/501c" title="501c">501(c)(3)</a> du code fiscal des États-Unis.</span><br /></li>
</ul>
<ul id="footer-places">
<li id="footer-places-privacy"><a href="https://foundation.wikimedia.org/wiki/Special:MyLanguage/Policy:Privacy_policy/fr">Politique de confidentialité</a></li>
<li id="footer-places-about"><a href="/wiki/Wikip%C3%A9dia:%C3%80_propos_de_Wikip%C3%A9dia">À propos de Wikipédia</a></li>
<li id="footer-places-disclaimers"><a href="/wiki/Wikip%C3%A9dia:Avertissements_g%C3%A9n%C3%A9raux">Avertissements</a></li>
<li id="footer-places-contact"><a href="//fr.wikipedia.org/wiki/Wikipédia:Contact">Contact</a></li>
<li id="footer-places-wm-codeofconduct"><a href="https://foundation.wikimedia.org/wiki/Special:MyLanguage/Policy:Universal_Code_of_Conduct">Code de conduite</a></li>
<li id="footer-places-developers"><a href="https://developer.wikimedia.org">Développeurs</a></li>
<li id="footer-places-statslink"><a href="https://stats.wikimedia.org/#/fr.wikipedia.org">Statistiques</a></li>
<li id="footer-places-cookiestatement"><a href="https://foundation.wikimedia.org/wiki/Special:MyLanguage/Policy:Cookie_statement">Déclaration sur les témoins (cookies)</a></li>
<li id="footer-places-mobileview"><a href="//fr.m.wikipedia.org/w/index.php?title=Miniflux&amp;mobileaction=toggle_view_mobile" class="noprint stopMobileRedirectToggle">Version mobile</a></li>
</ul>
<ul id="footer-icons" class="noprint">
<li id="footer-copyrightico"><a href="https://wikimediafoundation.org/"><img src="/static/images/footer/wikimedia-button.png" srcset="/static/images/footer/wikimedia-button-1.5x.png 1.5x, /static/images/footer/wikimedia-button-2x.png 2x" width="88" height="31" alt="Wikimedia Foundation" loading="lazy" /></a></li>
<li id="footer-poweredbyico"><a href="https://www.mediawiki.org/"><img src="/static/images/footer/poweredby_mediawiki_88x31.png" alt="Powered by MediaWiki" srcset="/static/images/footer/poweredby_mediawiki_132x47.png 1.5x, /static/images/footer/poweredby_mediawiki_176x62.png 2x" width="88" height="31" loading="lazy"></a></li>
</ul>
</footer>
</div>
</div>
</div>
<div class="vector-settings" id="p-dock-bottom">
<ul>
<li>
<button class="cdx-button cdx-button--icon-only vector-limited-width-toggle" id=""><span class="vector-icon mw-ui-icon-fullScreen mw-ui-icon-wikimedia-fullScreen"></span>
<span>Activer ou désactiver la limitation de largeur du contenu</span>
</button>
</li>
</ul>
</div>
<script>(RLQ=window.RLQ||[]).push(function(){mw.config.set({"wgHostname":"mw1401","wgBackendResponseTime":136,"wgPageParseReport":{"limitreport":{"cputime":"0.369","walltime":"0.494","ppvisitednodes":{"value":3432,"limit":1000000},"postexpandincludesize":{"value":58705,"limit":2097152},"templateargumentsize":{"value":16628,"limit":2097152},"expansiondepth":{"value":21,"limit":100},"expensivefunctioncount":{"value":6,"limit":500},"unstrip-depth":{"value":0,"limit":20},"unstrip-size":{"value":2070,"limit":5000000},"entityaccesscount":{"value":1,"limit":400},"timingprofile":["100.00% 449.099 1 -total"," 52.56% 236.049 45 Modèle:Wikidata"," 52.35% 235.089 1 Modèle:Infobox_Logiciel"," 34.76% 156.108 23 Modèle:Infobox_V3/Tableau_Ligne_mixte"," 21.42% 96.191 1 Modèle:Ébauche"," 11.91% 53.472 1 Modèle:Palette"," 10.43% 46.825 1 Modèle:Palette_Agrégateurs"," 9.96% 44.723 1 Modèle:Méta_palette_de_navigation"," 8.15% 36.590 13 Modèle:Infobox_V3/Tableau_Ligne_mixte_Wikidata"," 8.02% 36.009 2 Modèle:Méta_palette_de_navigation_sous-groupe"]},"scribunto":{"limitreport-timeusage":{"value":"0.226","limit":"10.000"},"limitreport-memusage":{"value":10407160,"limit":52428800}},"cachereport":{"origin":"mw-web.eqiad.canary-7c9994f4f8-6g6bc","timestamp":"20240304111906","ttl":2592000,"transientcontent":false}}});});</script>
<script type="application/ld+json">{"@context":"https:\/\/schema.org","@type":"Article","name":"Miniflux","url":"https:\/\/fr.wikipedia.org\/wiki\/Miniflux","sameAs":"http:\/\/www.wikidata.org\/entity\/Q16664605","mainEntity":"http:\/\/www.wikidata.org\/entity\/Q16664605","author":{"@type":"Organization","name":"Contributeurs aux projets Wikimedia"},"publisher":{"@type":"Organization","name":"Fondation Wikimedia, Inc.","logo":{"@type":"ImageObject","url":"https:\/\/www.wikimedia.org\/static\/images\/wmf-hor-googpub.png"}},"datePublished":"2013-04-14T21:28:24Z","dateModified":"2023-05-17T03:04:00Z","headline":"lecteur RSS pour serveur Web en Golang"}</script>
</body>
</html>

View File

@ -78,10 +78,9 @@ func findContentUsingCustomRules(page io.Reader, rules string) (string, error) {
contents := ""
document.Find(rules).Each(func(i int, s *goquery.Selection) {
var content string
content, _ = goquery.OuterHtml(s)
contents += content
if content, err := goquery.OuterHtml(s); err == nil {
contents += content
}
})
return contents, nil
@ -89,13 +88,11 @@ func findContentUsingCustomRules(page io.Reader, rules string) (string, error) {
func getPredefinedScraperRules(websiteURL string) string {
urlDomain := urllib.Domain(websiteURL)
urlDomain = strings.TrimPrefix(urlDomain, "www.")
for domain, rules := range predefinedRules {
if strings.Contains(urlDomain, domain) {
return rules
}
if rules, ok := predefinedRules[urlDomain]; ok {
return rules
}
return ""
}

View File

@ -19,6 +19,10 @@ func TestGetPredefinedRules(t *testing.T) {
t.Error("Unable to find rule for linux.com")
}
if getPredefinedScraperRules("https://linux.com/") == "" {
t.Error("Unable to find rule for linux.com")
}
if getPredefinedScraperRules("https://example.org/") != "" {
t.Error("A rule not defined should not return anything")
}

View File

@ -23,8 +23,8 @@ import (
)
var (
youtubeChannelRegex = regexp.MustCompile(`youtube\.com/channel/(.*)`)
youtubeVideoRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)`)
youtubeChannelRegex = regexp.MustCompile(`youtube\.com/channel/(.*)$`)
youtubeVideoRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
)
type SubscriptionFinder struct {
@ -196,11 +196,14 @@ func (f *SubscriptionFinder) FindSubscriptionsFromWebPage(websiteURL, contentTyp
func (f *SubscriptionFinder) FindSubscriptionsFromWellKnownURLs(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
knownURLs := map[string]string{
"atom.xml": parser.FormatAtom,
"feed.xml": parser.FormatAtom,
"feed/": parser.FormatAtom,
"rss.xml": parser.FormatRSS,
"rss/": parser.FormatRSS,
"atom.xml": parser.FormatAtom,
"feed.xml": parser.FormatAtom,
"feed/": parser.FormatAtom,
"rss.xml": parser.FormatRSS,
"rss/": parser.FormatRSS,
"index.rss": parser.FormatRSS,
"index.xml": parser.FormatRSS,
"feed.atom": parser.FormatAtom,
}
websiteURLRoot := urllib.RootURL(websiteURL)
@ -229,17 +232,19 @@ func (f *SubscriptionFinder) FindSubscriptionsFromWellKnownURLs(websiteURL strin
f.requestBuilder.WithoutRedirects()
responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(fullURL))
defer responseHandler.Close()
localizedError := responseHandler.LocalizedError()
responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
if localizedError != nil {
slog.Debug("Unable to subscribe", slog.String("fullURL", fullURL), slog.Any("error", localizedError.Error()))
continue
}
subscription := new(Subscription)
subscription.Type = kind
subscription.Title = fullURL
subscription.URL = fullURL
subscriptions = append(subscriptions, subscription)
subscriptions = append(subscriptions, &Subscription{
Type: kind,
Title: fullURL,
URL: fullURL,
})
}
}
@ -267,7 +272,7 @@ func (f *SubscriptionFinder) FindSubscriptionsFromRSSBridge(websiteURL, rssBridg
return nil, nil
}
var subscriptions Subscriptions
subscriptions := make(Subscriptions, 0, len(bridges))
for _, bridge := range bridges {
subscriptions = append(subscriptions, &Subscription{
Title: bridge.BridgeMeta.Name,

View File

@ -17,7 +17,7 @@ func NewSubscription(title, url, kind string) *Subscription {
}
func (s Subscription) String() string {
return fmt.Sprintf(`Title="%s", URL="%s", Type="%s"`, s.Title, s.URL, s.Type)
return fmt.Sprintf(`Title=%q, URL=%q, Type=%q`, s.Title, s.URL, s.Type)
}
// Subscriptions represents a list of subscription.

View File

@ -14,13 +14,14 @@ import (
)
// NewXMLDecoder returns a XML decoder that filters illegal characters.
func NewXMLDecoder(data io.Reader) *xml.Decoder {
func NewXMLDecoder(data io.ReadSeeker) *xml.Decoder {
var decoder *xml.Decoder
buffer, _ := io.ReadAll(data)
enc := procInst("encoding", string(buffer))
if enc != "" && enc != "utf-8" && enc != "UTF-8" && !strings.EqualFold(enc, "utf-8") {
// filter invalid chars later within decoder.CharsetReader
decoder = xml.NewDecoder(bytes.NewReader(buffer))
data.Seek(0, io.SeekStart)
decoder = xml.NewDecoder(data)
} else {
// filter invalid chars now, since decoder.CharsetReader not called for utf-8 content
filteredBytes := bytes.Map(filterValidXMLChar, buffer)

Some files were not shown because too many files have changed in this diff Show More