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 fe6c000897
25 changed files with 2612 additions and 1433 deletions

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,14 +87,24 @@ func Migrate(db *sql.DB) error {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
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 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 {
@ -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 '', -- TODO: adapt code (comma or JSON)
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
},
}