1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-09-30 19:22:11 +00:00

feat: multi db support

This commit is contained in:
haras 2025-09-14 08:50:42 +02:00
parent 10b2b36895
commit e9b8279241
27 changed files with 2770 additions and 1464 deletions

2
.envrc Normal file
View file

@ -0,0 +1,2 @@
use flake . --show-trace
dotenv_if_exists .env

1
.gitignore vendored
View file

@ -5,3 +5,4 @@
*.deb
*.rpm
miniflux-*
.direnv/

44
.helix/languages.toml Normal file
View file

@ -0,0 +1,44 @@
[language-server]
nil = { command = "nil" }
taplo = { command = "taplo", args = ["lsp", "stdio"] }
yaml-language-server = { command = "yaml-language-server", args = ["--stdio"] }
marksman = { command = "marksman", args = ["server"] }
vscode-json-language-server = { command = "vscode-json-language-server", args = [
"--stdio",
], config = { json = { validate = { enable = true } } } }
gopls = { command = "gopls", config = { staticcheck = true } }
[[language]]
name = "nix"
auto-format = true
formatter = { command = "nixpkgs-fmt" }
language-servers = ["nil"]
[[language]]
name = "toml"
auto-format = true
language-servers = ["taplo"]
[[language]]
name = "yaml"
auto-format = true
formatter = { command = "prettier", args = ["--parser", "yaml"] }
language-servers = ["yaml-language-server"]
[[language]]
name = "json"
auto-format = true
formatter = { command = "prettier", args = ["--parser", "json"] }
language-servers = ["vscode-json-language-server"]
[[language]]
name = "markdown"
auto-format = true
formatter = { command = "prettier", args = ["--parser", "markdown"] }
language-servers = ["marksman"]
[[language]]
name = "go"
auto-format = true
language-servers = ["gopls"]
formatter = { command = "gofumpt" }

View file

@ -37,17 +37,21 @@ When reporting bugs:
- **Git**
- **Go >= 1.24**
- **PostgreSQL**
- **CockroachDB**
- **SQLite**
### Getting Started
1. **Fork the repository** on GitHub
2. **Clone your fork locally:**
```bash
git clone https://github.com/YOUR_USERNAME/miniflux.git
cd miniflux
```
3. **Build the application binary:**
```bash
make miniflux
```
@ -59,15 +63,11 @@ When reporting bugs:
### Database Setup
For development and testing, you can run a local PostgreSQL database with Docker:
For development and testing, you can run PostgreSQL and CockroachDB via docker compose:
```bash
# Start PostgreSQL container
docker run --rm --name miniflux2-db -p 5432:5432 \
-e POSTGRES_DB=miniflux2 \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
postgres
# Start PostgreSQL and CockroachDB containers
docker compose -d
```
You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE_URL` environment variable accordingly.
@ -77,20 +77,27 @@ You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE
### Code Quality
1. **Run the linter:**
```bash
make lint
```
Requires `staticcheck` and `golangci-lint` to be installed.
2. **Run unit tests:**
```bash
make test
```
3. **Run integration tests:**
```bash
make integration-test
make clean-integration-test
make integration-test-postgresql
make clean-integration-test-postgresql
make integration-test-cockroachdb
make clean-integration-test-cockroachdb
make integration-test-sqlite
make clean-integration-test-sqlite
```
### Building
@ -103,6 +110,7 @@ You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE
### Cross-Platform Support
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
- Linux (amd64, arm64, armv7, armv6, armv5)
- macOS (amd64, arm64)
- FreeBSD, OpenBSD, Windows (amd64)
@ -155,11 +163,13 @@ When creating a pull request, please include:
## Testing
### Unit Tests
- Write unit tests for new functions and methods
- Ensure tests are fast and don't require external dependencies
- Aim for good test coverage
### Integration Tests
- Add integration tests for new API endpoints
- Tests run against a real PostgreSQL database
- Ensure tests clean up after themselves

View file

@ -3,7 +3,6 @@ DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --exact-match 2>/dev/null)
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DOCKER_PLATFORM := amd64
export PGPASSWORD := postgres
@ -28,8 +27,12 @@ export PGPASSWORD := postgres
add-string \
test \
lint \
integration-test \
clean-integration-test \
integration-test-postgresql \
clean-integration-test-postgresql \
integration-test-cockroachdb \
clean-integration-test-cockroachdb \
integration-test-sqlite \
clean-integration-test-sqlite \
docker-image \
docker-image-distroless \
docker-images \
@ -103,17 +106,18 @@ lint:
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;'
psql -U postgres -c 'create database miniflux_test;'
integration-test-postgresql:
psql -U postgres -p 5432 -d postgres -c 'drop database if exists miniflux2;'
psql -U postgres -p 5432 -d postgres -c 'create database miniflux2;'
DATABASE_URL=$(DB_URL) \
go build -o miniflux-test main.go
DATABASE_URL='postgres://postgres:postgres@localhost:5432/miniflux2?sslmode=disable' \
ADMIN_USERNAME=admin \
ADMIN_PASSWORD=test123 \
CREATE_ADMIN=1 \
RUN_MIGRATIONS=1 \
LOG_LEVEL=debug \
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
@ -122,10 +126,57 @@ integration-test:
TEST_MINIFLUX_ADMIN_PASSWORD=test123 \
go test -v -count=1 ./internal/api
clean-integration-test:
clean-integration-test-postgresql:
@ kill -9 `cat /tmp/miniflux.pid`
@ rm -f /tmp/miniflux.pid /tmp/miniflux.log
@ psql -U postgres -c 'drop database if exists miniflux_test;'
@ psql -U postgres -d postgres -c 'drop database if exists miniflux2;'
integration-test-cockroachdb:
psql -U postgres -d postgres -p 26257 -c 'drop database if exists miniflux2;'
psql -U postgres -d postgres -p 26257 -c 'create database miniflux2;'
go build -o miniflux-test main.go
DATABASE_URL='cockroach://postgres:postgres@localhost:26257/miniflux2?sslmode=disable' \
ADMIN_USERNAME=admin \
ADMIN_PASSWORD=test123 \
CREATE_ADMIN=1 \
RUN_MIGRATIONS=1 \
LOG_LEVEL=debug \
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
TEST_MINIFLUX_BASE_URL=http://127.0.0.1:8080 \
TEST_MINIFLUX_ADMIN_USERNAME=admin \
TEST_MINIFLUX_ADMIN_PASSWORD=test123 \
go test -v -count=1 ./internal/api
clean-integration-test-cockroachdb:
@ kill -9 `cat /tmp/miniflux.pid`
@ rm -f /tmp/miniflux.pid /tmp/miniflux.log
@ psql -U postgres -d postgres -c 'drop database if exists miniflux2;'
integration-test-sqlite:
go build -o miniflux-test main.go
DATABASE_URL='file::memory:?cache=shared&_pragma=foreign_keys(ON)' \
ADMIN_USERNAME=admin \
ADMIN_PASSWORD=test123 \
CREATE_ADMIN=1 \
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
TEST_MINIFLUX_BASE_URL=http://127.0.0.1:8080 \
TEST_MINIFLUX_ADMIN_USERNAME=admin \
TEST_MINIFLUX_ADMIN_PASSWORD=test123 \
go test -v -count=1 ./internal/api
clean-integration-test-sqlite:
@ kill -9 `cat /tmp/miniflux.pid`
@ rm -f /tmp/miniflux.pid /tmp/miniflux.log
@ rm miniflux-test
docker-image:
docker build --pull -t $(DOCKER_IMAGE):$(VERSION) -f packaging/docker/alpine/Dockerfile .

View file

@ -1,13 +1,11 @@
Miniflux 2
==========
# Miniflux 2
Miniflux is a minimalist and opinionated feed reader.
It's simple, fast, lightweight and super easy to install.
Official website: <https://miniflux.app>
Features
--------
## Features
### Feed Reader
@ -19,7 +17,7 @@ Features
- Share individual articles publicly.
- Fetches website icons (favicons).
- Saves articles to third-party services.
- Provides full-text search (powered by Postgres).
- Provides full-text search.
- Available in 20 languages: Portuguese (Brazilian), Chinese (Simplified and Traditional), Dutch, English (US), Finnish, French, German, Greek, Hindi, Indonesian, Italian, Japanese, Polish, Romanian, Russian, Taiwanese POJ, Ukrainian, Spanish, and Turkish.
### Privacy and Security
@ -90,7 +88,7 @@ Features
- Written in [Go (Golang)](https://golang.org/).
- Single binary compiled statically without dependency.
- Works only with [PostgreSQL](https://www.postgresql.org/).
- Works with [PostgreSQL](https://www.postgresql.org/), [CockroachDB](https://www.cockroachlabs.com/) and [SQLite](https://sqlite.org/).
- Does not use any ORM or any complicated frameworks.
- Uses modern vanilla JavaScript only when necessary.
- All static files are bundled into the application binary using the Go `embed` package.
@ -109,8 +107,7 @@ Features
- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.
- Respects/sends Last-Modified, If-Modified-Since, If-None-Match, Cache-Control, Expires and ETags headers, and has a default polling interval of 1h.
Documentation
-------------
## Documentation
The Miniflux documentation is available here: <https://miniflux.app/docs/> ([Man page](https://miniflux.app/miniflux.1.html))
@ -130,8 +127,7 @@ The Miniflux documentation is available here: <https://miniflux.app/docs/> ([Man
- [Internationalization](https://miniflux.app/docs/i18n.html)
- [Frequently Asked Questions](https://miniflux.app/faq.html)
Screenshots
-----------
## Screenshots
Default theme:
@ -141,8 +137,7 @@ Dark theme when using keyboard navigation:
![Dark theme](https://miniflux.app/images/item-selection-black-theme.png)
Credits
-------
## Credits
- Authors: Frédéric Guillot - [List of contributors](https://github.com/miniflux/v2/graphs/contributors)
- Distributed under Apache 2.0 License

View file

@ -4,8 +4,8 @@
# Provides: miniflux
# Required-Start: $syslog $network
# Required-Stop: $syslog
# Should-Start: postgresql
# Should-Stop: postgresql
# Should-Start: postgresql cockroachdb
# Should-Stop: postgresql cockroachdb
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: A rss reader

37
docker-compose.yml Normal file
View file

@ -0,0 +1,37 @@
services:
postgresql:
image: postgres:17.6
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- 5432:5432
environment:
POSTGRES_DB: miniflux2
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD", "pg_isready -d miniflux2 -U postgres"]
timeout: 30s
interval: 5s
cockroachdb:
image: cockroachdb/cockroach:v23.2.28
command: start-single-node --insecure
volumes:
- cockroach-data:/cockroach/cockroach-data
environment:
COCKROACH_DATABASE: miniflux2
COCKROACH_USER: postgres
ports:
- "26257:26257"
- "26258:8080"
healthcheck:
test: ["CMD", "cockroach", "sql", "--insecure", "--host=localhost:26257", "-e", "select 1"]
timeout: 30s
interval: 5s
volumes:
postgres-data:
driver: local
cockroach-data:
driver: local

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1757545623,
"narHash": "sha256-mCxPABZ6jRjUQx3bPP4vjA68ETbPLNz9V2pk9tO7pRQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "8cd5ce828d5d1d16feff37340171a98fc3bf6526",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

59
flake.nix Normal file
View file

@ -0,0 +1,59 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{ nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
in
{
devShells.default = pkgs.mkShell {
PGHOST = "localhost";
PGPORT = 5432;
PGPASSWORD = "postgres";
PGUSER = "postgres";
PGDATABASE = "miniflux2";
COCKROACH_URL = "postgresql://postgres:postgres@localhost:26257/miniflux2";
COCKROACH_INSECURE = true;
packages = with pkgs; [
git
gnumake
foreman
nil
nixfmt-rfc-style
nodePackages.prettier
nodePackages.yaml-language-server
nodePackages.vscode-langservers-extracted
markdownlint-cli
nodePackages.markdown-link-check
marksman
taplo
go
gopls
go-tools
gofumpt
golangci-lint
postgresql
cockroachdb
sqlite
usql
];
};
}
);
}

9
go.mod
View file

@ -16,6 +16,7 @@ require (
golang.org/x/net v0.44.0
golang.org/x/oauth2 v0.31.0
golang.org/x/term v0.35.0
modernc.org/sqlite v1.39.0
)
require (
@ -28,21 +29,29 @@ require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
go 1.24.0

45
go.sum
View file

@ -13,6 +13,8 @@ github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmr
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
@ -28,6 +30,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@ -42,10 +46,14 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@ -56,6 +64,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@ -85,6 +95,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -92,6 +104,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@ -112,12 +126,15 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@ -153,6 +170,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
@ -161,3 +180,29 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -168,7 +168,13 @@ func Parse() {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
}
kind, err := database.DetectKind(config.Opts.DatabaseURL())
if err != nil {
printErrorAndExit(fmt.Errorf("unable to parse database kind: %v", err))
}
db, err := database.NewConnectionPool(
kind,
config.Opts.DatabaseURL(),
config.Opts.DatabaseMinConns(),
config.Opts.DatabaseMaxConns(),
@ -179,14 +185,14 @@ func Parse() {
}
defer db.Close()
store := storage.NewStorage(db)
store := storage.NewStorage(kind, db)
if err := store.Ping(); err != nil {
printErrorAndExit(err)
}
if flagMigrate {
if err := database.Migrate(db); err != nil {
if err := database.Migrate(kind, db); err != nil {
printErrorAndExit(err)
}
return
@ -228,12 +234,12 @@ func Parse() {
// Run migrations and start the daemon.
if config.Opts.RunMigrations() {
if err := database.Migrate(db); err != nil {
if err := database.Migrate(kind, db); err != nil {
printErrorAndExit(err)
}
}
if err := database.IsSchemaUpToDate(db); err != nil {
if err := database.IsSchemaUpToDate(kind, db); err != nil {
printErrorAndExit(err)
}

View file

@ -183,8 +183,8 @@ func NewConfigOptions() *configOptions {
},
},
"DATABASE_URL": {
ParsedStringValue: "user=postgres password=postgres dbname=miniflux2 sslmode=disable",
RawValue: "user=postgres password=postgres dbname=miniflux2 sslmode=disable",
ParsedStringValue: "postgres://postgres:postgres/postgres?sslmode=disable",
RawValue: "postgres://postgres:postgres/postgres?sslmode=disable",
ValueType: stringType,
Secret: true,
},
@ -779,7 +779,7 @@ func (c *configOptions) IsAuthProxyUserCreationAllowed() bool {
}
func (c *configOptions) IsDefaultDatabaseURL() bool {
return c.options["DATABASE_URL"].RawValue == "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
return c.options["DATABASE_URL"].RawValue == "postgres://postgres:postgres/postgres?sslmode=disable"
}
func (c *configOptions) IsOAuth2UserCreationAllowed() bool {

View file

@ -348,7 +348,7 @@ func TestDatabaseMinConnsOptionParsing(t *testing.T) {
func TestDatabaseURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DatabaseURL() != "user=postgres password=postgres dbname=miniflux2 sslmode=disable" {
if configParser.options.DatabaseURL() != "postgres://postgres:postgres/postgres?sslmode=disable" {
t.Fatal("Expected DATABASE_URL to have default value")
}

View file

@ -0,0 +1,396 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
_ "github.com/lib/pq"
)
var cockroachSchemaVersion = len(cockroachMigrations)
// Order is important. Add new migrations at the end of the list.
var cockroachMigrations = []Migration{
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE schema_version (
version STRING NOT NULL,
rowid INT8 NOT VISIBLE NOT NULL DEFAULT unique_rowid(),
CONSTRAINT schema_version_pkey PRIMARY KEY (rowid ASC)
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE entry_status AS ENUM ('unread', 'read', 'removed');
CREATE TYPE entry_sorting_direction AS ENUM ('asc', 'desc');
CREATE TYPE webapp_display_mode AS ENUM ('fullscreen', 'standalone', 'minimal-ui', 'browser');
CREATE TYPE entry_sorting_order AS ENUM ('published_at', 'created_at');
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE users (
id INT8 NOT NULL DEFAULT unique_rowid(),
username STRING NOT NULL,
password STRING NULL,
is_admin BOOL NULL DEFAULT false,
language STRING NULL DEFAULT 'en_US':::STRING,
timezone STRING NULL DEFAULT 'UTC':::STRING,
theme STRING NULL DEFAULT 'light_serif':::STRING,
last_login_at TIMESTAMPTZ NULL,
entry_direction entry_sorting_direction NULL DEFAULT 'asc':::entry_sorting_direction,
keyboard_shortcuts BOOL NULL DEFAULT true,
entries_per_page INT8 NULL DEFAULT 100:::INT8,
show_reading_time BOOL NULL DEFAULT true,
entry_swipe BOOL NULL DEFAULT true,
stylesheet STRING NOT NULL DEFAULT '':::STRING,
google_id STRING NOT NULL DEFAULT '':::STRING,
openid_connect_id STRING NOT NULL DEFAULT '':::STRING,
display_mode webapp_display_mode NULL DEFAULT 'standalone':::webapp_display_mode,
entry_order entry_sorting_order NULL DEFAULT 'published_at':::entry_sorting_order,
default_reading_speed INT8 NULL DEFAULT 265:::INT8,
cjk_reading_speed INT8 NULL DEFAULT 500:::INT8,
default_home_page STRING NULL DEFAULT 'unread':::STRING,
categories_sorting_order STRING NOT NULL DEFAULT 'unread_count':::STRING,
gesture_nav STRING NOT NULL DEFAULT 'tap':::STRING,
mark_read_on_view BOOL NULL DEFAULT true,
media_playback_rate DECIMAL NULL DEFAULT 1:::DECIMAL,
block_filter_entry_rules STRING NOT NULL DEFAULT '':::STRING,
keep_filter_entry_rules STRING NOT NULL DEFAULT '':::STRING,
mark_read_on_media_player_completion BOOL NULL DEFAULT false,
custom_js STRING NOT NULL DEFAULT '':::STRING,
external_font_hosts STRING NOT NULL DEFAULT '':::STRING,
always_open_external_links BOOL NULL DEFAULT false,
open_external_links_in_new_tab BOOL NULL DEFAULT true,
CONSTRAINT users_pkey PRIMARY KEY (id ASC),
UNIQUE INDEX users_username_key (username ASC),
UNIQUE INDEX users_google_id_idx (google_id ASC) WHERE google_id != '':::STRING,
UNIQUE INDEX users_openid_connect_id_idx (openid_connect_id ASC) WHERE openid_connect_id != '':::STRING
);
CREATE TABLE user_sessions (
id INT8 NOT NULL DEFAULT unique_rowid(),
user_id INT8 NOT NULL,
token STRING NOT NULL,
created_at TIMESTAMPTZ NULL DEFAULT now():::TIMESTAMPTZ,
user_agent STRING NULL,
ip STRING NULL,
CONSTRAINT sessions_pkey PRIMARY KEY (id ASC),
UNIQUE INDEX sessions_token_key (token ASC),
UNIQUE INDEX sessions_user_id_token_key (user_id ASC, token ASC)
);
CREATE TABLE categories (
id INT8 NOT NULL DEFAULT unique_rowid(),
user_id INT8 NOT NULL,
title STRING NOT NULL,
hide_globally BOOL NOT NULL DEFAULT false,
CONSTRAINT categories_pkey PRIMARY KEY (id ASC),
UNIQUE INDEX categories_user_id_title_key (user_id ASC, title ASC)
);
CREATE TABLE feeds (
id INT8 NOT NULL DEFAULT unique_rowid(),
user_id INT8 NOT NULL,
category_id INT8 NOT NULL,
title STRING NOT NULL,
feed_url STRING NOT NULL,
site_url STRING NOT NULL,
checked_at TIMESTAMPTZ NULL DEFAULT now():::TIMESTAMPTZ,
etag_header STRING NULL DEFAULT '':::STRING,
last_modified_header STRING NULL DEFAULT '':::STRING,
parsing_error_msg STRING NULL DEFAULT '':::STRING,
parsing_error_count INT8 NULL DEFAULT 0:::INT8,
scraper_rules STRING NULL DEFAULT '':::STRING,
rewrite_rules STRING NULL DEFAULT '':::STRING,
crawler BOOL NULL DEFAULT false,
username STRING NULL DEFAULT '':::STRING,
password STRING NULL DEFAULT '':::STRING,
user_agent STRING NULL DEFAULT '':::STRING,
disabled BOOL NULL DEFAULT false,
next_check_at TIMESTAMPTZ NULL DEFAULT now():::TIMESTAMPTZ,
ignore_http_cache BOOL NULL DEFAULT false,
fetch_via_proxy BOOL NULL DEFAULT false,
blocklist_rules STRING NOT NULL DEFAULT '':::STRING,
keeplist_rules STRING NOT NULL DEFAULT '':::STRING,
allow_self_signed_certificates BOOL NOT NULL DEFAULT false,
cookie STRING NULL DEFAULT '':::STRING,
hide_globally BOOL NOT NULL DEFAULT false,
url_rewrite_rules STRING NOT NULL DEFAULT '':::STRING,
no_media_player BOOL NULL DEFAULT false,
apprise_service_urls STRING NULL DEFAULT '':::STRING,
disable_http2 BOOL NULL DEFAULT false,
description STRING NULL DEFAULT '':::STRING,
ntfy_enabled BOOL NULL DEFAULT false,
ntfy_priority INT8 NULL DEFAULT 3:::INT8,
webhook_url STRING NULL DEFAULT '':::STRING,
pushover_enabled BOOL NULL DEFAULT false,
pushover_priority INT8 NULL DEFAULT 0:::INT8,
ntfy_topic STRING NULL DEFAULT '':::STRING,
proxy_url STRING NULL DEFAULT '':::STRING,
block_filter_entry_rules STRING NOT NULL DEFAULT '':::STRING,
keep_filter_entry_rules STRING NOT NULL DEFAULT '':::STRING,
CONSTRAINT feeds_pkey PRIMARY KEY (id ASC),
UNIQUE INDEX feeds_user_id_feed_url_key (user_id ASC, feed_url ASC),
INDEX feeds_user_category_idx (user_id ASC, category_id ASC),
INDEX feeds_feed_id_hide_globally_idx (id ASC, hide_globally ASC)
);
CREATE TABLE entries (
id INT8 NOT NULL DEFAULT unique_rowid(),
user_id INT8 NOT NULL,
feed_id INT8 NOT NULL,
hash STRING NOT NULL,
published_at TIMESTAMPTZ NOT NULL,
title STRING NOT NULL,
url STRING NOT NULL,
author STRING NULL,
content STRING NULL,
status entry_status NULL DEFAULT 'unread':::entry_status,
starred BOOL NULL DEFAULT false,
comments_url STRING NULL DEFAULT '':::STRING,
document_vectors TSVECTOR NULL,
changed_at TIMESTAMPTZ NOT NULL,
share_code STRING NOT NULL DEFAULT '':::STRING,
reading_time INT8 NOT NULL DEFAULT 0:::INT8,
created_at TIMESTAMPTZ NOT NULL DEFAULT now():::TIMESTAMPTZ,
tags STRING[] NULL DEFAULT ARRAY[]:::STRING[],
CONSTRAINT entries_pkey PRIMARY KEY (id ASC),
UNIQUE INDEX entries_feed_id_hash_key (feed_id ASC, hash ASC),
INDEX entries_feed_idx (feed_id ASC),
INDEX entries_user_status_idx (user_id ASC, status ASC),
INVERTED INDEX document_vectors_idx (document_vectors),
UNIQUE INDEX entries_share_code_idx (share_code ASC) WHERE share_code != '':::STRING,
INDEX entries_user_feed_idx (user_id ASC, feed_id ASC),
INDEX entries_id_user_status_idx (id ASC, user_id ASC, status ASC),
INDEX entries_feed_id_status_hash_idx (feed_id ASC, status ASC, hash ASC),
INDEX entries_user_id_status_starred_idx (user_id ASC, status ASC, starred ASC),
INDEX entries_user_status_feed_idx (user_id ASC, status ASC, feed_id ASC),
INDEX entries_user_status_changed_idx (user_id ASC, status ASC, changed_at ASC),
INDEX entries_user_status_published_idx (user_id ASC, status ASC, published_at ASC),
INDEX entries_user_status_created_idx (user_id ASC, status ASC, created_at ASC),
INDEX entries_user_status_changed_published_idx (user_id ASC, status ASC, changed_at ASC, published_at ASC)
);
CREATE TABLE enclosures (
id INT8 NOT NULL DEFAULT unique_rowid(),
user_id INT8 NOT NULL,
entry_id INT8 NOT NULL,
url STRING NOT NULL,
size INT8 NULL DEFAULT 0:::INT8,
mime_type STRING NULL DEFAULT '':::STRING,
media_progression INT8 NULL DEFAULT 0:::INT8,
CONSTRAINT enclosures_pkey PRIMARY KEY (id ASC),
UNIQUE INDEX enclosures_user_entry_url_unique_idx (user_id ASC, entry_id ASC, url ASC),
INDEX enclosures_entry_id_idx (entry_id ASC)
);
CREATE TABLE icons (
id INT8 NOT NULL DEFAULT unique_rowid(),
hash STRING NOT NULL,
mime_type STRING NOT NULL,
content BYTES NOT NULL,
external_id STRING NULL DEFAULT '':::STRING,
CONSTRAINT icons_pkey PRIMARY KEY (id ASC),
UNIQUE INDEX icons_hash_key (hash ASC),
UNIQUE INDEX icons_external_id_idx (external_id ASC) WHERE external_id != '':::STRING
);
CREATE TABLE feed_icons (
feed_id INT8 NOT NULL,
icon_id INT8 NOT NULL,
CONSTRAINT feed_icons_pkey PRIMARY KEY (feed_id ASC, icon_id ASC)
);
CREATE TABLE integrations (
user_id INT8 NOT NULL,
pinboard_enabled BOOL NULL DEFAULT false,
pinboard_token STRING NULL DEFAULT '':::STRING,
pinboard_tags STRING NULL DEFAULT 'miniflux':::STRING,
pinboard_mark_as_unread BOOL NULL DEFAULT false,
instapaper_enabled BOOL NULL DEFAULT false,
instapaper_username STRING NULL DEFAULT '':::STRING,
instapaper_password STRING NULL DEFAULT '':::STRING,
fever_enabled BOOL NULL DEFAULT false,
fever_username STRING NULL DEFAULT '':::STRING,
fever_token STRING NULL DEFAULT '':::STRING,
wallabag_enabled BOOL NULL DEFAULT false,
wallabag_url STRING NULL DEFAULT '':::STRING,
wallabag_client_id STRING NULL DEFAULT '':::STRING,
wallabag_client_secret STRING NULL DEFAULT '':::STRING,
wallabag_username STRING NULL DEFAULT '':::STRING,
wallabag_password STRING NULL DEFAULT '':::STRING,
nunux_keeper_enabled BOOL NULL DEFAULT false,
nunux_keeper_url STRING NULL DEFAULT '':::STRING,
nunux_keeper_api_key STRING NULL DEFAULT '':::STRING,
telegram_bot_enabled BOOL NULL DEFAULT false,
telegram_bot_token STRING NULL DEFAULT '':::STRING,
telegram_bot_chat_id STRING NULL DEFAULT '':::STRING,
googlereader_enabled BOOL NULL DEFAULT false,
googlereader_username STRING NULL DEFAULT '':::STRING,
googlereader_password STRING NULL DEFAULT '':::STRING,
espial_enabled BOOL NULL DEFAULT false,
espial_url STRING NULL DEFAULT '':::STRING,
espial_api_key STRING NULL DEFAULT '':::STRING,
espial_tags STRING NULL DEFAULT 'miniflux':::STRING,
linkding_enabled BOOL NULL DEFAULT false,
linkding_url STRING NULL DEFAULT '':::STRING,
linkding_api_key STRING NULL DEFAULT '':::STRING,
wallabag_only_url BOOL NULL DEFAULT false,
matrix_bot_enabled BOOL NULL DEFAULT false,
matrix_bot_user STRING NULL DEFAULT '':::STRING,
matrix_bot_password STRING NULL DEFAULT '':::STRING,
matrix_bot_url STRING NULL DEFAULT '':::STRING,
matrix_bot_chat_id STRING NULL DEFAULT '':::STRING,
linkding_tags STRING NULL DEFAULT '':::STRING,
linkding_mark_as_unread BOOL NULL DEFAULT false,
notion_enabled BOOL NULL DEFAULT false,
notion_token STRING NULL DEFAULT '':::STRING,
notion_page_id STRING NULL DEFAULT '':::STRING,
readwise_enabled BOOL NULL DEFAULT false,
readwise_api_key STRING NULL DEFAULT '':::STRING,
apprise_enabled BOOL NULL DEFAULT false,
apprise_url STRING NULL DEFAULT '':::STRING,
apprise_services_url STRING NULL DEFAULT '':::STRING,
shiori_enabled BOOL NULL DEFAULT false,
shiori_url STRING NULL DEFAULT '':::STRING,
shiori_username STRING NULL DEFAULT '':::STRING,
shiori_password STRING NULL DEFAULT '':::STRING,
shaarli_enabled BOOL NULL DEFAULT false,
shaarli_url STRING NULL DEFAULT '':::STRING,
shaarli_api_secret STRING NULL DEFAULT '':::STRING,
webhook_enabled BOOL NULL DEFAULT false,
webhook_url STRING NULL DEFAULT '':::STRING,
webhook_secret STRING NULL DEFAULT '':::STRING,
telegram_bot_topic_id INT8 NULL,
telegram_bot_disable_web_page_preview BOOL NULL DEFAULT false,
telegram_bot_disable_notification BOOL NULL DEFAULT false,
telegram_bot_disable_buttons BOOL NULL DEFAULT false,
rssbridge_enabled BOOL NULL DEFAULT false,
rssbridge_url STRING NULL DEFAULT '':::STRING,
omnivore_enabled BOOL NULL DEFAULT false,
omnivore_api_key STRING NULL DEFAULT '':::STRING,
omnivore_url STRING NULL DEFAULT '':::STRING,
linkace_enabled BOOL NULL DEFAULT false,
linkace_url STRING NULL DEFAULT '':::STRING,
linkace_api_key STRING NULL DEFAULT '':::STRING,
linkace_tags STRING NULL DEFAULT '':::STRING,
linkace_is_private BOOL NULL DEFAULT true,
linkace_check_disabled BOOL NULL DEFAULT true,
linkwarden_enabled BOOL NULL DEFAULT false,
linkwarden_url STRING NULL DEFAULT '':::STRING,
linkwarden_api_key STRING NULL DEFAULT '':::STRING,
readeck_enabled BOOL NULL DEFAULT false,
readeck_only_url BOOL NULL DEFAULT false,
readeck_url STRING NULL DEFAULT '':::STRING,
readeck_api_key STRING NULL DEFAULT '':::STRING,
readeck_labels STRING NULL DEFAULT '':::STRING,
raindrop_enabled BOOL NULL DEFAULT false,
raindrop_token STRING NULL DEFAULT '':::STRING,
raindrop_collection_id STRING NULL DEFAULT '':::STRING,
raindrop_tags STRING NULL DEFAULT '':::STRING,
betula_url STRING NULL DEFAULT '':::STRING,
betula_token STRING NULL DEFAULT '':::STRING,
betula_enabled BOOL NULL DEFAULT false,
ntfy_enabled BOOL NULL DEFAULT false,
ntfy_url STRING NULL DEFAULT '':::STRING,
ntfy_topic STRING NULL DEFAULT '':::STRING,
ntfy_api_token STRING NULL DEFAULT '':::STRING,
ntfy_username STRING NULL DEFAULT '':::STRING,
ntfy_password STRING NULL DEFAULT '':::STRING,
ntfy_icon_url STRING NULL DEFAULT '':::STRING,
cubox_enabled BOOL NULL DEFAULT false,
cubox_api_link STRING NULL DEFAULT '':::STRING,
discord_enabled BOOL NULL DEFAULT false,
discord_webhook_link STRING NULL DEFAULT '':::STRING,
ntfy_internal_links BOOL NULL DEFAULT false,
slack_enabled BOOL NULL DEFAULT false,
slack_webhook_link STRING NULL DEFAULT '':::STRING,
pushover_enabled BOOL NULL DEFAULT false,
pushover_user STRING NULL DEFAULT '':::STRING,
pushover_token STRING NULL DEFAULT '':::STRING,
pushover_device STRING NULL DEFAULT '':::STRING,
pushover_prefix STRING NULL DEFAULT '':::STRING,
rssbridge_token STRING NULL DEFAULT '':::STRING,
karakeep_enabled BOOL NULL DEFAULT false,
karakeep_api_key STRING NULL DEFAULT '':::STRING,
karakeep_url STRING NULL DEFAULT '':::STRING,
CONSTRAINT integrations_pkey PRIMARY KEY (user_id ASC)
);
CREATE TABLE sessions (
id STRING NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now():::TIMESTAMPTZ,
CONSTRAINT sessions_pkey PRIMARY KEY (id ASC)
);
CREATE TABLE api_keys (
id INT8 NOT NULL DEFAULT unique_rowid(),
user_id INT8 NOT NULL,
token STRING NOT NULL,
description STRING NOT NULL,
last_used_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NULL DEFAULT now():::TIMESTAMPTZ,
CONSTRAINT api_keys_pkey PRIMARY KEY (id ASC),
UNIQUE INDEX api_keys_token_key (token ASC),
UNIQUE INDEX api_keys_user_id_description_key (user_id ASC, description ASC)
);
CREATE TABLE acme_cache (
key VARCHAR(400) NOT NULL,
data BYTES NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT acme_cache_pkey PRIMARY KEY (key ASC)
);
CREATE TABLE webauthn_credentials (
handle BYTES NOT NULL,
cred_id BYTES NOT NULL,
user_id INT8 NOT NULL,
key BYTES NOT NULL,
attestation_type VARCHAR(255) NOT NULL,
aaguid BYTES NULL,
sign_count INT8 NULL,
clone_warning BOOL NULL,
name STRING NULL,
added_on TIMESTAMPTZ NULL DEFAULT now():::TIMESTAMPTZ,
last_seen_on TIMESTAMPTZ NULL DEFAULT now():::TIMESTAMPTZ,
CONSTRAINT webauthn_credentials_pkey PRIMARY KEY (handle ASC),
UNIQUE INDEX webauthn_credentials_cred_id_key (cred_id ASC)
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE user_sessions ADD CONSTRAINT sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE categories ADD CONSTRAINT categories_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE feeds ADD CONSTRAINT feeds_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE feeds ADD CONSTRAINT feeds_category_id_fkey FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE;
ALTER TABLE entries ADD CONSTRAINT entries_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE entries ADD CONSTRAINT entries_feed_id_fkey FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE;
ALTER TABLE enclosures ADD CONSTRAINT enclosures_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE enclosures ADD CONSTRAINT enclosures_entry_id_fkey FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE;
ALTER TABLE feed_icons ADD CONSTRAINT feed_icons_feed_id_fkey FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE;
ALTER TABLE feed_icons ADD CONSTRAINT feed_icons_icon_id_fkey FOREIGN KEY (icon_id) REFERENCES icons(id) ON DELETE CASCADE;
ALTER TABLE api_keys ADD CONSTRAINT api_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE webauthn_credentials ADD CONSTRAINT webauthn_credentials_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; `
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE user_sessions VALIDATE CONSTRAINT sessions_user_id_fkey;
ALTER TABLE categories VALIDATE CONSTRAINT categories_user_id_fkey;
ALTER TABLE feeds VALIDATE CONSTRAINT feeds_user_id_fkey;
ALTER TABLE feeds VALIDATE CONSTRAINT feeds_category_id_fkey;
ALTER TABLE entries VALIDATE CONSTRAINT entries_user_id_fkey;
ALTER TABLE entries VALIDATE CONSTRAINT entries_feed_id_fkey;
ALTER TABLE enclosures VALIDATE CONSTRAINT enclosures_user_id_fkey;
ALTER TABLE enclosures VALIDATE CONSTRAINT enclosures_entry_id_fkey;
ALTER TABLE feed_icons VALIDATE CONSTRAINT feed_icons_feed_id_fkey;
ALTER TABLE feed_icons VALIDATE CONSTRAINT feed_icons_icon_id_fkey;
ALTER TABLE api_keys VALIDATE CONSTRAINT api_keys_user_id_fkey;
ALTER TABLE webauthn_credentials VALIDATE CONSTRAINT webauthn_credentials_user_id_fkey;
`
_, err = tx.Exec(sql)
return err
},
}

View file

@ -7,13 +7,68 @@ import (
"database/sql"
"fmt"
"log/slog"
"strings"
"time"
)
type DBKind int
const (
DBKindPostgres DBKind = iota
DBKindCockroach
DBKindSqlite
)
var dbKindProto = map[DBKind]string{
DBKindPostgres: "postgresql",
DBKindCockroach: "cockroachdb",
DBKindSqlite: "sqlite",
}
var dbKindDriver = map[DBKind]string{
DBKindPostgres: "postgres",
DBKindCockroach: "postgres",
DBKindSqlite: "sqlite",
}
func DetectKind(conn string) (DBKind, error) {
switch {
case strings.HasPrefix(conn, "postgres"),
strings.HasPrefix(conn, "postgresql"):
return DBKindPostgres, nil
case strings.HasPrefix(conn, "cockroach"),
strings.HasPrefix(conn, "cockroachdb"):
return DBKindCockroach, nil
case strings.HasPrefix(conn, "file"),
strings.HasPrefix(conn, "sqlite"):
return DBKindSqlite, nil
default:
return 0, fmt.Errorf("unknown db kind in conn string: %q", conn)
}
}
type Migration func(*sql.Tx) error
var dbKindMigrations = map[DBKind][]Migration{
DBKindPostgres: postgresMigrations,
DBKindCockroach: cockroachMigrations,
DBKindSqlite: sqliteMigrations,
}
var dbKindSchemaVersion = map[DBKind]int{
DBKindPostgres: postgresSchemaVersion,
DBKindCockroach: cockroachSchemaVersion,
DBKindSqlite: sqliteSchemaVersion,
}
// Migrate executes database migrations.
func Migrate(db *sql.DB) error {
func Migrate(kind DBKind, db *sql.DB) error {
var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)
migrations := dbKindMigrations[kind]
schemaVersion := dbKindSchemaVersion[kind]
slog.Info("Running database migrations",
slog.Int("current_version", currentVersion),
slog.Int("latest_version", schemaVersion),
@ -32,15 +87,25 @@ func Migrate(db *sql.DB) error {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if kind == DBKindSqlite {
if _, err := tx.Exec(`DELETE FROM schema_version`); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES (?)`, newVersion); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
} else {
if _, err := tx.Exec(`TRUNCATE schema_version`); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES ($1)`, newVersion); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
@ -51,7 +116,9 @@ func Migrate(db *sql.DB) error {
}
// IsSchemaUpToDate checks if the database schema is up to date.
func IsSchemaUpToDate(db *sql.DB) error {
func IsSchemaUpToDate(kind DBKind, db *sql.DB) error {
schemaVersion := dbKindSchemaVersion[kind]
var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)
if currentVersion < schemaVersion {
@ -59,3 +126,25 @@ func IsSchemaUpToDate(db *sql.DB) error {
}
return nil
}
func NewConnectionPool(kind DBKind, dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) {
driver := dbKindDriver[kind]
// replace cockroachdb protocol with postgres
// we use cockroachdb protocol to detect cockroachdb but go wants postgres
if kind == DBKindCockroach {
split := strings.SplitN(dsn, ":", 2)
dsn = fmt.Sprintf("postgres:%s", split[1])
}
db, err := sql.Open(driver, dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxConnections)
db.SetMaxIdleConns(minConnections)
db.SetConnMaxLifetime(connectionLifetime)
return db, nil
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

378
internal/database/sqlite.go Normal file
View file

@ -0,0 +1,378 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
_ "modernc.org/sqlite"
)
var sqliteSchemaVersion = len(sqliteMigrations)
// Order is important. Add new migrations at the end of the list.
var sqliteMigrations = []Migration{
func(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE schema_version (
version INTEGER NOT NULL
);
`)
return err
},
func(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
language TEXT NOT NULL DEFAULT 'en_US',
timezone TEXT NOT NULL DEFAULT 'UTC',
theme TEXT NOT NULL DEFAULT 'light_serif',
last_login_at DATETIME,
entry_direction TEXT NOT NULL DEFAULT 'asc' CHECK (entry_direction IN ('asc','desc')),
keyboard_shortcuts INTEGER NOT NULL DEFAULT 1,
entries_per_page INTEGER NOT NULL DEFAULT 100,
show_reading_time INTEGER NOT NULL DEFAULT 1,
entry_swipe INTEGER NOT NULL DEFAULT 1,
stylesheet TEXT NOT NULL DEFAULT '',
google_id TEXT NOT NULL DEFAULT '',
openid_connect_id TEXT NOT NULL DEFAULT '',
display_mode TEXT NOT NULL DEFAULT 'standalone' CHECK (display_mode IN ('fullscreen','standalone','minimal-ui','browser')),
entry_order TEXT NOT NULL DEFAULT 'published_at' CHECK (entry_order IN ('published_at','created_at')),
default_reading_speed INTEGER NOT NULL DEFAULT 265,
cjk_reading_speed INTEGER NOT NULL DEFAULT 500,
default_home_page TEXT NOT NULL DEFAULT 'unread',
categories_sorting_order TEXT NOT NULL DEFAULT 'unread_count',
gesture_nav TEXT NOT NULL DEFAULT 'tap',
mark_read_on_view INTEGER NOT NULL DEFAULT 1,
media_playback_rate REAL NOT NULL DEFAULT 1.0,
block_filter_entry_rules TEXT NOT NULL DEFAULT '',
keep_filter_entry_rules TEXT NOT NULL DEFAULT '',
mark_read_on_media_player_completion INTEGER NOT NULL DEFAULT 0,
custom_js TEXT NOT NULL DEFAULT '',
external_font_hosts TEXT NOT NULL DEFAULT '',
always_open_external_links INTEGER NOT NULL DEFAULT 0,
open_external_links_in_new_tab INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE user_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT (DATETIME('now')),
user_agent TEXT,
ip TEXT,
UNIQUE(user_id, token),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
hide_globally INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id, title),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
title TEXT NOT NULL,
feed_url TEXT NOT NULL,
site_url TEXT NOT NULL,
checked_at DATETIME NOT NULL DEFAULT (DATETIME('now')),
etag_header TEXT NOT NULL DEFAULT '',
last_modified_header TEXT NOT NULL DEFAULT '',
parsing_error_msg TEXT NOT NULL DEFAULT '',
parsing_error_count INTEGER NOT NULL DEFAULT 0,
scraper_rules TEXT NOT NULL DEFAULT '',
rewrite_rules TEXT NOT NULL DEFAULT '',
crawler INTEGER NOT NULL DEFAULT 0,
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
disabled INTEGER NOT NULL DEFAULT 0,
next_check_at DATETIME NOT NULL DEFAULT (DATETIME('now')),
ignore_http_cache INTEGER NOT NULL DEFAULT 0,
fetch_via_proxy INTEGER NOT NULL DEFAULT 0,
blocklist_rules TEXT NOT NULL DEFAULT '',
keeplist_rules TEXT NOT NULL DEFAULT '',
allow_self_signed_certificates INTEGER NOT NULL DEFAULT 0,
cookie TEXT NOT NULL DEFAULT '',
hide_globally INTEGER NOT NULL DEFAULT 0,
url_rewrite_rules TEXT NOT NULL DEFAULT '',
no_media_player INTEGER NOT NULL DEFAULT 0,
apprise_service_urls TEXT NOT NULL DEFAULT '',
disable_http2 INTEGER NOT NULL DEFAULT 0,
description TEXT NOT NULL DEFAULT '',
ntfy_enabled INTEGER NOT NULL DEFAULT 0,
ntfy_priority INTEGER NOT NULL DEFAULT 3,
webhook_url TEXT NOT NULL DEFAULT '',
pushover_enabled INTEGER NOT NULL DEFAULT 0,
pushover_priority INTEGER NOT NULL DEFAULT 0,
ntfy_topic TEXT NOT NULL DEFAULT '',
proxy_url TEXT NOT NULL DEFAULT '',
block_filter_entry_rules TEXT NOT NULL DEFAULT '',
keep_filter_entry_rules TEXT NOT NULL DEFAULT '',
UNIQUE(user_id, feed_url),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
feed_id INTEGER NOT NULL,
hash TEXT NOT NULL,
published_at DATETIME NOT NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
author TEXT,
content TEXT,
status TEXT NOT NULL DEFAULT 'unread' CHECK (status IN ('unread','read','removed')),
starred INTEGER NOT NULL DEFAULT 0,
comments_url TEXT NOT NULL DEFAULT '',
changed_at DATETIME NOT NULL,
share_code TEXT NOT NULL DEFAULT '',
reading_time INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT (DATETIME('now')),
tags TEXT NOT NULL DEFAULT '',
UNIQUE(feed_id, hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE
);
CREATE TABLE enclosures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
entry_id INTEGER NOT NULL,
url TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT NOT NULL DEFAULT '',
media_progression INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id, entry_id, url),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
);
CREATE TABLE icons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT NOT NULL,
mime_type TEXT NOT NULL,
content BLOB NOT NULL,
external_id TEXT NOT NULL DEFAULT '',
UNIQUE(hash)
);
CREATE TABLE feed_icons (
feed_id INTEGER NOT NULL,
icon_id INTEGER NOT NULL,
PRIMARY KEY (feed_id, icon_id),
FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
FOREIGN KEY (icon_id) REFERENCES icons(id) ON DELETE CASCADE
);
CREATE TABLE integrations (
user_id INTEGER PRIMARY KEY,
pinboard_enabled INTEGER NOT NULL DEFAULT 0,
pinboard_token TEXT NOT NULL DEFAULT '',
pinboard_tags TEXT NOT NULL DEFAULT 'miniflux',
pinboard_mark_as_unread INTEGER NOT NULL DEFAULT 0,
instapaper_enabled INTEGER NOT NULL DEFAULT 0,
instapaper_username TEXT NOT NULL DEFAULT '',
instapaper_password TEXT NOT NULL DEFAULT '',
fever_enabled INTEGER NOT NULL DEFAULT 0,
fever_username TEXT NOT NULL DEFAULT '',
fever_token TEXT NOT NULL DEFAULT '',
wallabag_enabled INTEGER NOT NULL DEFAULT 0,
wallabag_url TEXT NOT NULL DEFAULT '',
wallabag_client_id TEXT NOT NULL DEFAULT '',
wallabag_client_secret TEXT NOT NULL DEFAULT '',
wallabag_username TEXT NOT NULL DEFAULT '',
wallabag_password TEXT NOT NULL DEFAULT '',
nunux_keeper_enabled INTEGER NOT NULL DEFAULT 0,
nunux_keeper_url TEXT NOT NULL DEFAULT '',
nunux_keeper_api_key TEXT NOT NULL DEFAULT '',
telegram_bot_enabled INTEGER NOT NULL DEFAULT 0,
telegram_bot_token TEXT NOT NULL DEFAULT '',
telegram_bot_chat_id TEXT NOT NULL DEFAULT '',
googlereader_enabled INTEGER NOT NULL DEFAULT 0,
googlereader_username TEXT NOT NULL DEFAULT '',
googlereader_password TEXT NOT NULL DEFAULT '',
espial_enabled INTEGER NOT NULL DEFAULT 0,
espial_url TEXT NOT NULL DEFAULT '',
espial_api_key TEXT NOT NULL DEFAULT '',
espial_tags TEXT NOT NULL DEFAULT 'miniflux',
linkding_enabled INTEGER NOT NULL DEFAULT 0,
linkding_url TEXT NOT NULL DEFAULT '',
linkding_api_key TEXT NOT NULL DEFAULT '',
wallabag_only_url INTEGER NOT NULL DEFAULT 0,
matrix_bot_enabled INTEGER NOT NULL DEFAULT 0,
matrix_bot_user TEXT NOT NULL DEFAULT '',
matrix_bot_password TEXT NOT NULL DEFAULT '',
matrix_bot_url TEXT NOT NULL DEFAULT '',
matrix_bot_chat_id TEXT NOT NULL DEFAULT '',
linkding_tags TEXT NOT NULL DEFAULT '',
linkding_mark_as_unread INTEGER NOT NULL DEFAULT 0,
notion_enabled INTEGER NOT NULL DEFAULT 0,
notion_token TEXT NOT NULL DEFAULT '',
notion_page_id TEXT NOT NULL DEFAULT '',
readwise_enabled INTEGER NOT NULL DEFAULT 0,
readwise_api_key TEXT NOT NULL DEFAULT '',
apprise_enabled INTEGER NOT NULL DEFAULT 0,
apprise_url TEXT NOT NULL DEFAULT '',
apprise_services_url TEXT NOT NULL DEFAULT '',
shiori_enabled INTEGER NOT NULL DEFAULT 0,
shiori_url TEXT NOT NULL DEFAULT '',
shiori_username TEXT NOT NULL DEFAULT '',
shiori_password TEXT NOT NULL DEFAULT '',
shaarli_enabled INTEGER NOT NULL DEFAULT 0,
shaarli_url TEXT NOT NULL DEFAULT '',
shaarli_api_secret TEXT NOT NULL DEFAULT '',
webhook_enabled INTEGER NOT NULL DEFAULT 0,
webhook_url TEXT NOT NULL DEFAULT '',
webhook_secret TEXT NOT NULL DEFAULT '',
telegram_bot_topic_id INTEGER,
telegram_bot_disable_web_page_preview INTEGER NOT NULL DEFAULT 0,
telegram_bot_disable_notification INTEGER NOT NULL DEFAULT 0,
telegram_bot_disable_buttons INTEGER NOT NULL DEFAULT 0,
rssbridge_enabled INTEGER NOT NULL DEFAULT 0,
rssbridge_url TEXT NOT NULL DEFAULT '',
omnivore_enabled INTEGER NOT NULL DEFAULT 0,
omnivore_api_key TEXT NOT NULL DEFAULT '',
omnivore_url TEXT NOT NULL DEFAULT '',
linkace_enabled INTEGER NOT NULL DEFAULT 0,
linkace_url TEXT NOT NULL DEFAULT '',
linkace_api_key TEXT NOT NULL DEFAULT '',
linkace_tags TEXT NOT NULL DEFAULT '',
linkace_is_private INTEGER NOT NULL DEFAULT 1,
linkace_check_disabled INTEGER NOT NULL DEFAULT 1,
linkwarden_enabled INTEGER NOT NULL DEFAULT 0,
linkwarden_url TEXT NOT NULL DEFAULT '',
linkwarden_api_key TEXT NOT NULL DEFAULT '',
readeck_enabled INTEGER NOT NULL DEFAULT 0,
readeck_only_url INTEGER NOT NULL DEFAULT 0,
readeck_url TEXT NOT NULL DEFAULT '',
readeck_api_key TEXT NOT NULL DEFAULT '',
readeck_labels TEXT NOT NULL DEFAULT '',
raindrop_enabled INTEGER NOT NULL DEFAULT 0,
raindrop_token TEXT NOT NULL DEFAULT '',
raindrop_collection_id TEXT NOT NULL DEFAULT '',
raindrop_tags TEXT NOT NULL DEFAULT '',
betula_url TEXT NOT NULL DEFAULT '',
betula_token TEXT NOT NULL DEFAULT '',
betula_enabled INTEGER NOT NULL DEFAULT 0,
ntfy_enabled INTEGER NOT NULL DEFAULT 0,
ntfy_url TEXT NOT NULL DEFAULT '',
ntfy_topic TEXT NOT NULL DEFAULT '',
ntfy_api_token TEXT NOT NULL DEFAULT '',
ntfy_username TEXT NOT NULL DEFAULT '',
ntfy_password TEXT NOT NULL DEFAULT '',
ntfy_icon_url TEXT NOT NULL DEFAULT '',
cubox_enabled INTEGER NOT NULL DEFAULT 0,
cubox_api_link TEXT NOT NULL DEFAULT '',
discord_enabled INTEGER NOT NULL DEFAULT 0,
discord_webhook_link TEXT NOT NULL DEFAULT '',
ntfy_internal_links INTEGER NOT NULL DEFAULT 0,
slack_enabled INTEGER NOT NULL DEFAULT 0,
slack_webhook_link TEXT NOT NULL DEFAULT '',
pushover_enabled INTEGER NOT NULL DEFAULT 0,
pushover_user TEXT NOT NULL DEFAULT '',
pushover_token TEXT NOT NULL DEFAULT '',
pushover_device TEXT NOT NULL DEFAULT '',
pushover_prefix TEXT NOT NULL DEFAULT '',
rssbridge_token TEXT NOT NULL DEFAULT '',
karakeep_enabled INTEGER NOT NULL DEFAULT 0,
karakeep_api_key TEXT NOT NULL DEFAULT '',
karakeep_url TEXT NOT NULL DEFAULT '',
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT (DATETIME('now'))
);
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
last_used_at DATETIME,
created_at DATETIME NOT NULL DEFAULT (DATETIME('now')),
UNIQUE(user_id, description),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE acme_cache (
key TEXT PRIMARY KEY,
data BLOB NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE webauthn_credentials (
handle BLOB PRIMARY KEY,
cred_id BLOB NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
key BLOB NOT NULL,
attestation_type TEXT NOT NULL,
aaguid BLOB,
sign_count INTEGER,
clone_warning INTEGER,
name TEXT,
added_on DATETIME NOT NULL DEFAULT (DATETIME('now')),
last_seen_on DATETIME NOT NULL DEFAULT (DATETIME('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`)
return err
},
func(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE UNIQUE INDEX icons_external_id_uq ON icons(external_id) WHERE external_id != '';
CREATE UNIQUE INDEX users_google_id_uq ON users(google_id) WHERE google_id != '';
CREATE UNIQUE INDEX users_openid_connect_id_uq ON users(openid_connect_id) WHERE openid_connect_id != '';
CREATE UNIQUE INDEX entries_share_code_uq ON entries(share_code) WHERE share_code != '';
CREATE INDEX feeds_user_category_idx ON feeds(user_id, category_id);
CREATE INDEX feeds_id_hide_globally_idx ON feeds(id, hide_globally);
CREATE INDEX entries_feed_idx ON entries(feed_id);
CREATE INDEX entries_user_status_idx ON entries(user_id, status);
CREATE INDEX entries_user_feed_idx ON entries(user_id, feed_id);
CREATE INDEX entries_user_status_changed_idx ON entries(user_id, status, changed_at);
CREATE INDEX entries_user_status_published_idx ON entries(user_id, status, published_at);
CREATE INDEX entries_user_status_created_idx ON entries(user_id, status, created_at);
CREATE INDEX entries_id_user_status_idx ON entries(id, user_id, status);
CREATE INDEX entries_feed_id_status_hash_idx ON entries(feed_id, status, hash);
CREATE INDEX entries_user_id_status_starred_idx ON entries(user_id, status, starred);
CREATE INDEX entries_user_status_feed_idx ON entries(user_id, status, feed_id);
CREATE INDEX entries_user_status_changed_published_idx ON entries(user_id, status, changed_at, published_at);
CREATE INDEX enclosures_entry_id_idx ON enclosures(entry_id);
CREATE INDEX feed_icons_icon_id_idx ON feed_icons(icon_id);
`)
return err
},
func(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE VIRTUAL TABLE entries_fts USING fts5(
title,
content,
tags,
entry_id UNINDEXED,
content='',
-- as close to PostgreSQL as possible while being multilingual
tokenize = "unicode61 remove_diacritics 2 tokenchars '-_'"
);
CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
INSERT INTO entries_fts(rowid,title,content,tags,entry_id)
VALUES (new.id,new.title,COALESCE(new.content,''),COALESCE(new.tags,''),new.id);
END;
CREATE TRIGGER entries_au AFTER UPDATE ON entries BEGIN
INSERT INTO entries_fts(entries_fts,rowid) VALUES('delete', old.id);
INSERT INTO entries_fts(rowid,title,content,tags,entry_id)
VALUES (new.id,new.title,COALESCE(new.content,''),COALESCE(new.tags,''),new.id);
END;
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
INSERT INTO entries_fts(entries_fts,rowid) VALUES('delete', old.id);
END;
`)
return err
},
}

View file

@ -8,6 +8,7 @@ import (
"fmt"
"strings"
"miniflux.app/v2/internal/database"
"miniflux.app/v2/internal/model"
"github.com/lib/pq"
@ -49,7 +50,6 @@ func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) {
&enclosure.MimeType,
&enclosure.MediaProgression,
)
if err != nil {
return nil, fmt.Errorf(`store: unable to fetch enclosure row: %v`, err)
}
@ -150,15 +150,20 @@ func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error
return nil
}
query := `
urlPart := "md5(url)"
if s.kind == database.DBKindCockroach {
urlPart = "url"
}
query := fmt.Sprintf(`
INSERT INTO enclosures
(url, size, mime_type, entry_id, user_id, media_progression)
VALUES
($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, entry_id, md5(url)) DO NOTHING
ON CONFLICT (user_id, entry_id, %s) DO NOTHING
RETURNING
id
`
`, urlPart)
if err := tx.QueryRow(
query,
enclosureURL,
@ -226,7 +231,6 @@ func (s *Storage) UpdateEnclosure(enclosure *model.Enclosure) error {
enclosure.MediaProgression,
enclosure.ID,
)
if err != nil {
return fmt.Errorf(`store: unable to update enclosure #%d : %v`, enclosure.ID, err)
}

View file

@ -5,12 +5,14 @@ package storage // import "miniflux.app/v2/internal/storage"
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/database"
"miniflux.app/v2/internal/model"
"github.com/lib/pq"
@ -70,17 +72,24 @@ func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {
// UpdateEntryTitleAndContent updates entry title and content.
func (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error {
truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
query := `
vectorPart := ""
if s.kind != database.DBKindSqlite {
vectorPart = ", document_vectors = setweight(to_tsvector($4), 'A') || setweight(to_tsvector($5), 'B')"
if s.kind == database.DBKindCockroach {
vectorPart = ", document_vectors = to_tsvector(substring($4 || ' ' || $4 || ' ' || coalesce($5, '') for 1000000))"
}
}
query := fmt.Sprintf(`
UPDATE
entries
SET
title=$1,
content=$2,
reading_time=$3,
document_vectors = setweight(to_tsvector($4), 'A') || setweight(to_tsvector($5), 'B')
reading_time=$3
%s
WHERE
id=$6 AND user_id=$7
`
`, vectorPart)
if _, err := s.db.Exec(
query,
@ -100,7 +109,14 @@ func (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error {
// createEntry add a new entry.
func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
query := `
vectorParts := [2]string{"", ""}
if s.kind != database.DBKindSqlite {
vectorParts = [2]string{",document_vectors", ",setweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B')"}
if s.kind == database.DBKindCockroach {
vectorParts[1] = ",to_tsvector(substring($11 || ' ' || $11 || ' ' || coalesce($12, '') for 1000000))"
}
}
query := fmt.Sprintf(`
INSERT INTO entries
(
title,
@ -114,8 +130,8 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
feed_id,
reading_time,
changed_at,
document_vectors,
tags
%s
)
VALUES
(
@ -130,12 +146,24 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
$9,
$10,
now(),
setweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B'),
$13
%s
)
RETURNING
id, status, created_at, changed_at
`
`, vectorParts[0], vectorParts[1])
var tagsParam any
if s.kind == database.DBKindSqlite {
sqliteTagsParam, err := encodeTagsJSON(entry.Tags)
if err != nil {
return fmt.Errorf("encode tags: %w", err)
}
tagsParam = sqliteTagsParam
} else {
tagsParam = pq.Array(entry.Tags)
}
err := tx.QueryRow(
query,
entry.Title,
@ -150,7 +178,7 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
entry.ReadingTime,
truncatedTitle,
truncatedContent,
pq.Array(entry.Tags),
tagsParam,
).Scan(
&entry.ID,
&entry.Status,
@ -178,7 +206,14 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
// it default to time.Now() which could change the order of items on the history page.
func (s *Storage) updateEntry(tx *sql.Tx, entry *model.Entry) error {
truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
query := `
vectorPart := ""
if s.kind != database.DBKindSqlite {
vectorPart = ",document_vectors = setweight(to_tsvector($7), 'A') || setweight(to_tsvector($8), 'B')"
if s.kind == database.DBKindCockroach {
vectorPart = ",document_vectors = to_tsvector(substring($7 || ' ' || $7 || ' ' || coalesce($8, '') for 1000000))"
}
}
query := fmt.Sprintf(`
UPDATE
entries
SET
@ -188,13 +223,25 @@ func (s *Storage) updateEntry(tx *sql.Tx, entry *model.Entry) error {
content=$4,
author=$5,
reading_time=$6,
document_vectors = setweight(to_tsvector($7), 'A') || setweight(to_tsvector($8), 'B'),
tags=$12
%s
WHERE
user_id=$9 AND feed_id=$10 AND hash=$11
RETURNING
id
`
`, vectorPart)
var tagsParam any
if s.kind == database.DBKindSqlite {
sqliteTagsParam, err := encodeTagsJSON(entry.Tags)
if err != nil {
return fmt.Errorf("encode tags: %w", err)
}
tagsParam = sqliteTagsParam
} else {
tagsParam = pq.Array(entry.Tags)
}
err := tx.QueryRow(
query,
entry.Title,
@ -208,7 +255,7 @@ func (s *Storage) updateEntry(tx *sql.Tx, entry *model.Entry) error {
entry.UserID,
entry.FeedID,
entry.Hash,
pq.Array(entry.Tags),
tagsParam,
).Scan(&entry.ID)
if err != nil {
return fmt.Errorf(`store: unable to update entry %q: %v`, entry.URL, err)
@ -301,7 +348,12 @@ func (s *Storage) DeleteRemovedEntriesEnclosures() (int64, error) {
// ClearRemovedEntriesContent clears the content fields of entries marked as "removed", keeping only their metadata.
func (s *Storage) ClearRemovedEntriesContent(limit int) (int64, error) {
query := `
vectorPart := ""
if s.kind != database.DBKindSqlite {
vectorPart = ",document_vectors=NULL"
}
query := fmt.Sprintf(`
UPDATE
entries
SET
@ -309,8 +361,8 @@ func (s *Storage) ClearRemovedEntriesContent(limit int) (int64, error) {
content=NULL,
url='',
author=NULL,
comments_url=NULL,
document_vectors=NULL
comments_url=NULL
%s
WHERE id IN (
SELECT id
FROM entries
@ -318,7 +370,7 @@ func (s *Storage) ClearRemovedEntriesContent(limit int) (int64, error) {
ORDER BY id ASC
LIMIT $2
)
`
`, vectorPart)
result, err := s.db.Exec(query, model.EntryStatusRemoved, limit)
if err != nil {
@ -737,3 +789,19 @@ func truncateStringForTSVectorField(s string, maxSize int) string {
// Fallback: return empty string if we can't find a valid UTF-8 boundary
return ""
}
func encodeTagsJSON(tags []string) (string, error) {
b, err := json.Marshal(tags)
return string(b), err
}
func decodeTagsJSON(s string) ([]string, error) {
if s == "" {
return nil, nil
}
var tags []string
if err := json.Unmarshal([]byte(s), &tags); err != nil {
return nil, err
}
return tags, nil
}

View file

@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"miniflux.app/v2/internal/database"
"miniflux.app/v2/internal/model"
)
@ -20,13 +21,22 @@ type EntryPaginationBuilder struct {
entryID int64
order string
direction string
useSqliteFts bool
}
// WithSearchQuery adds full-text search query to the condition.
func (e *EntryPaginationBuilder) WithSearchQuery(query string) {
if query != "" {
e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ plainto_tsquery($%d)", len(e.args)+1))
nArgs := len(e.args) + 1
if e.store.kind == database.DBKindSqlite {
query = toSqliteFtsQuery(query)
e.useSqliteFts = true
e.conditions = append(e.conditions, fmt.Sprintf("fts MATCH $%d", nArgs))
e.args = append(e.args, query)
} else {
e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ plainto_tsquery($%d)", nArgs))
e.args = append(e.args, query)
}
}
}
@ -61,12 +71,27 @@ func (e *EntryPaginationBuilder) WithStatus(status string) {
func (e *EntryPaginationBuilder) WithTags(tags []string) {
if len(tags) > 0 {
if e.store.kind == database.DBKindSqlite {
for _, tag := range tags {
n := len(e.args) + 1
cond := fmt.Sprintf(`
EXISTS (
SELECT 1
FROM json_each(COALESCE(e.tags, '[]')) AS jt
WHERE lower(jt.value) = lower($%d)
)
`, n)
e.conditions = append(e.conditions, cond)
e.args = append(e.args, tag)
}
} else {
for _, tag := range tags {
e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
e.args = append(e.args, tag)
}
}
}
}
// WithGloballyVisible adds global visibility to the condition.
func (e *EntryPaginationBuilder) WithGloballyVisible() {

View file

@ -12,6 +12,7 @@ import (
"github.com/lib/pq"
"miniflux.app/v2/internal/database"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/timezone"
)
@ -25,6 +26,7 @@ type EntryQueryBuilder struct {
limit int
offset int
fetchEnclosures bool
useSqliteFts bool
}
// WithEnclosures fetches enclosures for each entry.
@ -37,6 +39,14 @@ func (e *EntryQueryBuilder) WithEnclosures() *EntryQueryBuilder {
func (e *EntryQueryBuilder) WithSearchQuery(query string) *EntryQueryBuilder {
if query != "" {
nArgs := len(e.args) + 1
if e.store.kind == database.DBKindSqlite {
query = toSqliteFtsQuery(query)
e.useSqliteFts = true
e.conditions = append(e.conditions, fmt.Sprintf("fts MATCH $%d", nArgs))
e.args = append(e.args, query)
e.WithSorting("bm25(fts)", "DESC")
} else {
e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ plainto_tsquery($%d)", nArgs))
e.args = append(e.args, query)
@ -46,6 +56,7 @@ func (e *EntryQueryBuilder) WithSearchQuery(query string) *EntryQueryBuilder {
"DESC",
)
}
}
return e
}
@ -160,11 +171,26 @@ func (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder {
// WithTags filter by a list of entry tags.
func (e *EntryQueryBuilder) WithTags(tags []string) *EntryQueryBuilder {
if len(tags) > 0 {
if e.store.kind == database.DBKindSqlite {
for _, tag := range tags {
n := len(e.args) + 1
cond := fmt.Sprintf(`
EXISTS (
SELECT 1
FROM json_each(COALESCE(e.tags, '[]')) AS jt
WHERE lower(jt.value) = lower($%d)
)
`, n)
e.conditions = append(e.conditions, cond)
e.args = append(e.args, tag)
}
} else {
for _, cat := range tags {
e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
e.args = append(e.args, cat)
}
}
}
return e
}
@ -307,7 +333,16 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
icons i ON i.id=fi.icon_id
LEFT JOIN
users u ON u.id=e.user_id
WHERE ` + e.buildCondition() + " " + e.buildSorting()
`
if e.useSqliteFts {
query += `
LEFT JOIN
entries_fts fts ON fts.rowid = e.id
`
}
query += " WHERE " + e.buildCondition() + " " + e.buildSorting()
rows, err := e.store.db.Query(query, e.args...)
if err != nil {
@ -364,7 +399,6 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
&externalIconID,
&tz,
)
if err != nil {
return nil, fmt.Errorf("store: unable to fetch entry row: %v", err)
}
@ -480,3 +514,16 @@ func NewAnonymousQueryBuilder(store *Storage) *EntryQueryBuilder {
store: store,
}
}
func toSqliteFtsQuery(q string) string {
q = strings.TrimSpace(q)
if q == "" {
return ""
}
terms := strings.Fields(q)
for i, t := range terms {
t = strings.ReplaceAll(t, `"`, `""`)
terms[i] = `"` + t + `"`
}
return strings.Join(terms, " AND ")
}

View file

@ -7,16 +7,19 @@ import (
"context"
"database/sql"
"time"
"miniflux.app/v2/internal/database"
)
// Storage handles all operations related to the database.
type Storage struct {
kind database.DBKind
db *sql.DB
}
// NewStorage returns a new Storage.
func NewStorage(db *sql.DB) *Storage {
return &Storage{db}
func NewStorage(kind database.DBKind, db *sql.DB) *Storage {
return &Storage{kind, db}
}
// DatabaseVersion returns the version of the database which is in use.

View file

@ -226,9 +226,9 @@ Minimum number of database connections\&.
Default is 1\&.
.TP
.B DATABASE_URL
Postgresql connection parameters\&.
Database connection parameters\&.
.br
Default is "user=postgres password=postgres dbname=miniflux2 sslmode=disable"\&.
Default is "postgres://postgres:postgres/postgres?sslmode=disable"\&.
.TP
.B DATABASE_URL_FILE
Path to a secret key exposed as a file, it should contain $DATABASE_URL value\&.

View file

@ -9,7 +9,7 @@
[Unit]
Description=Miniflux
Documentation=man:miniflux(1) https://miniflux.app/docs/index.html
After=network.target postgresql.service
After=network.target postgresql.service cockroachdb.service
[Service]
ExecStart=/usr/bin/miniflux