EagleCast
publish-github-pages / deploy (push) Waiting to run

This commit is contained in:
h202-wq
2026-07-09 10:03:32 -04:00
commit 66d9a033c9
446 changed files with 162542 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_4_0 performs the DB migrations for v.0.4.0.
func V0_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
_, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'list_optin') THEN
CREATE TYPE list_optin AS ENUM ('single', 'double');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'campaign_type') THEN
CREATE TYPE campaign_type AS ENUM ('regular', 'optin');
END IF;
END$$;
ALTER TABLE lists ADD COLUMN IF NOT EXISTS optin list_optin NOT NULL DEFAULT 'single';
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS type campaign_type DEFAULT 'regular';
`)
return err
}
+116
View File
@@ -0,0 +1,116 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_7_0 performs the DB migrations for v.0.7.0.
func V0_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Check if the subscriber_status.blocklisted enum value exists. If not,
// it has to be created (for the change from blacklisted -> blocklisted).
var bl bool
if err := db.Get(&bl, `SELECT 'blocklisted' = ANY(ENUM_RANGE(NULL::subscriber_status)::TEXT[])`); err != nil {
return err
}
// If `blocklist` doesn't exist, add it to the subscriber_status enum,
// and update existing statuses to this value. Unfortunately, it's not possible
// to remove the enum value `blacklisted` (until PG10).
if !bl {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`
-- Change the status column to text.
ALTER TABLE subscribers ALTER COLUMN status TYPE TEXT;
-- Change all statuses from 'blacklisted' to 'blocklisted'.
UPDATE subscribers SET status='blocklisted' WHERE status='blacklisted';
-- Remove the old enum.
DROP TYPE subscriber_status CASCADE;
-- Create new enum with the new values.
CREATE TYPE subscriber_status AS ENUM ('enabled', 'disabled', 'blocklisted');
-- Change the text status column to the new enum.
ALTER TABLE subscribers ALTER COLUMN status TYPE subscriber_status
USING (status::subscriber_status);
`); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
_, err := db.Exec(`
ALTER TABLE media DROP COLUMN IF EXISTS width,
DROP COLUMN IF EXISTS height,
ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT '';
-- 'blacklisted' to 'blocklisted' ENUM rename is not possible (until pg10),
-- so just add the new value and ignore the old one.
CREATE TABLE IF NOT EXISTS settings (
key TEXT NOT NULL UNIQUE,
value JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_settings_key ON settings(key);
-- Insert default settings if the table is empty.
INSERT INTO settings (key, value) SELECT k, v::JSONB FROM (VALUES
('app.root_url', '"http://localhost:9000"'),
('app.favicon_url', '""'),
('app.from_email', '"eaglecast <noreply@eaglecast.yoursite.com>"'),
('app.logo_url', '"http://localhost:9000/public/static/logo.png"'),
('app.concurrency', '10'),
('app.message_rate', '10'),
('app.batch_size', '1000'),
('app.max_send_errors', '1000'),
('app.notify_emails', '[]'),
('privacy.unsubscribe_header', 'true'),
('privacy.allow_blocklist', 'true'),
('privacy.allow_export', 'true'),
('privacy.allow_wipe', 'true'),
('privacy.exportable', '["profile", "subscriptions", "campaign_views", "link_clicks"]'),
('upload.provider', '"filesystem"'),
('upload.filesystem.upload_path', '"uploads"'),
('upload.filesystem.upload_uri', '"/uploads"'),
('upload.s3.aws_access_key_id', '""'),
('upload.s3.aws_secret_access_key', '""'),
('upload.s3.aws_default_region', '"ap-south-1"'),
('upload.s3.bucket', '""'),
('upload.s3.bucket_domain', '""'),
('upload.s3.bucket_path', '"/"'),
('upload.s3.bucket_type', '"public"'),
('upload.s3.expiry', '"14d"'),
('smtp',
'[{"enabled":true, "host":"smtp.yoursite.com","port":25,"auth_protocol":"cram","username":"username","password":"password","hello_hostname":"","max_conns":10,"idle_timeout":"15s","wait_timeout":"5s","max_msg_retries":2,"tls_enabled":true,"tls_skip_verify":false,"email_headers":[]},
{"enabled":false, "host":"smtp2.yoursite.com","port":587,"auth_protocol":"plain","username":"username","password":"password","hello_hostname":"","max_conns":10,"idle_timeout":"15s","wait_timeout":"5s","max_msg_retries":2,"tls_enabled":false,"tls_skip_verify":false,"email_headers":[]}]'),
('messengers', '[]')) vals(k, v) WHERE NOT EXISTS(SELECT * FROM settings LIMIT 1);
`)
if err != nil {
return err
}
// `provider` in the media table is a new field. If there's provider config available
// and no provider value exists in the media table, set it.
prov := ko.String("upload.provider")
if prov != "" {
if _, err := db.Exec(`UPDATE media SET provider=$1 WHERE provider=''`, prov); err != nil {
return err
}
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_8_0 performs the DB migrations for v.0.8.0.
func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
_, err := db.Exec(`
INSERT INTO settings (key, value) VALUES ('privacy.individual_tracking', 'false')
ON CONFLICT DO NOTHING;
INSERT INTO settings (key, value) VALUES ('messengers', '[]')
ON CONFLICT DO NOTHING;
-- Link clicks shouldn't exist if there's no corresponding link.
-- links_clicks.link_id should have been NOT NULL originally.
DELETE FROM link_clicks WHERE link_id is NULL;
ALTER TABLE link_clicks ALTER COLUMN link_id SET NOT NULL;
`)
return err
}
+42
View File
@@ -0,0 +1,42 @@
package migrations
import (
"fmt"
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_9_0 performs the DB migrations for v.0.9.0.
func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('app.lang', '"en"'),
('app.message_sliding_window', 'false'),
('app.message_sliding_window_duration', '"1h"'),
('app.message_sliding_window_rate', '10000'),
('app.enable_public_subscription_page', 'true')
ON CONFLICT DO NOTHING;
-- Add alternate (plain text) body field on campaigns.
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS altbody TEXT NULL DEFAULT NULL;
`); err != nil {
return err
}
// Until this version, the default template during installation was broken!
// Check if there's a broken default template and if yes, override it with the
// actual one.
tplBody, err := fs.Get("/static/email-templates/default.tpl")
if err != nil {
return fmt.Errorf("error reading default e-mail template: %v", err)
}
if _, err := db.Exec(`UPDATE templates SET body=$1 WHERE body=$2`,
tplBody.ReadBytes(), `{{ template "content" . }}`); err != nil {
return err
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V1_0_0 performs the DB migrations for v.1.0.0.
func V1_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'markdown'`); err != nil {
return err
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_0_0 performs the DB migrations for v.1.0.0.
func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'bounce_type') THEN
CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
END IF;
END$$;
`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS bounces (
id SERIAL PRIMARY KEY,
subscriber_id INTEGER NOT NULL REFERENCES subscribers(id) ON DELETE CASCADE ON UPDATE CASCADE,
campaign_id INTEGER NULL REFERENCES campaigns(id) ON DELETE SET NULL ON UPDATE CASCADE,
type bounce_type NOT NULL DEFAULT 'hard',
source TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bounces_sub_id ON bounces(subscriber_id);
CREATE INDEX IF NOT EXISTS idx_bounces_camp_id ON bounces(campaign_id);
CREATE INDEX IF NOT EXISTS idx_bounces_source ON bounces(source);
`); err != nil {
return err
}
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('app.send_optin_confirmation', 'true'),
('privacy.domain_blocklist', '[]'),
('bounce.enabled', 'false'),
('bounce.webhooks_enabled', 'false'),
('bounce.count', '2'),
('bounce.action', '"blocklist"'),
('bounce.ses_enabled', 'false'),
('bounce.sendgrid_enabled', 'false'),
('bounce.sendgrid_key', '""'),
('bounce.mailboxes', '[{"enabled":false, "type": "pop", "host":"pop.yoursite.com","port":995,"auth_protocol":"userpass","username":"username","password":"password","return_path": "bounce@eaglecast.yoursite.com","scan_interval":"15m","tls_enabled":true,"tls_skip_verify":false}]')
ON CONFLICT DO NOTHING;`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE subscribers DROP COLUMN IF EXISTS campaigns`); err != nil {
return err
}
if _, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'campaign_views_pkey') THEN
ALTER TABLE campaign_views ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'link_clicks_pkey') THEN
ALTER TABLE link_clicks ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'campaign_lists_pkey') THEN
ALTER TABLE campaign_lists ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
END IF;
END$$;
CREATE INDEX IF NOT EXISTS idx_views_date ON campaign_views((TIMEZONE('UTC', created_at)::DATE));
CREATE INDEX IF NOT EXISTS idx_clicks_date ON link_clicks((TIMEZONE('UTC', created_at)::DATE));
`); err != nil {
return err
}
// S3 URL i snow a settings field. Prepare S3 URL based on region and bucket.
if _, err := db.Exec(`
WITH region AS (
SELECT value#>>'{}' AS value FROM settings WHERE key='upload.s3.aws_default_region'
), s3url AS (
SELECT FORMAT('https://s3.%s.amazonaws.com', (SELECT value FROM region)) AS value
)
INSERT INTO settings (key, value) VALUES ('upload.s3.url', TO_JSON((SELECT * FROM s3url))) ON CONFLICT DO NOTHING;`); err != nil {
return err
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_1_0 performs the DB migrations for v.2.1.0.
func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Insert appearance related settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('appearance.admin.custom_css', '""'),
('appearance.admin.custom_js', '""'),
('appearance.public.custom_css', '""'),
('appearance.public.custom_js', '""'),
('upload.s3.public_url', '""')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
// Replace all `tls_enabled: true/false` keys in the `smtp` settings JSON array
// with the new field `tls_type: STARTTLS|TLS|none`.
// The `tls_enabled` key is removed.
if _, err := db.Exec(`
UPDATE settings SET value = s.updated
FROM (
SELECT JSONB_AGG(
JSONB_SET(v - 'tls_enabled', '{tls_type}', (CASE WHEN v->>'tls_enabled' = 'true' THEN '"STARTTLS"' ELSE '"none"' END)::JSONB)
) AS updated FROM settings, JSONB_ARRAY_ELEMENTS(value) v WHERE key = 'smtp'
) s WHERE key = 'smtp' AND value::TEXT LIKE '%tls_enabled%';
`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS headers JSONB NOT NULL DEFAULT '[]';`); err != nil {
return err
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_2_0 performs the DB migrations for v.2.2.0.
func V2_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'template_type') THEN
CREATE TYPE template_type AS ENUM ('campaign', 'tx');
END IF;
END$$;
`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS "type" template_type NOT NULL DEFAULT 'campaign'`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS subject TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE templates ALTER COLUMN subject DROP DEFAULT`); err != nil {
return err
}
// Insert transactional template.
txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl")
if err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO templates (name, type, subject, body) VALUES($1, $2, $3, $4)`,
"Sample transactional template", "tx", "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
return err
}
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_2_0 performs the DB migrations for v.2.3.0.
func V2_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS "meta" JSONB NOT NULL DEFAULT '{}'`); err != nil {
return err
}
// Add `description` field to lists.
if _, err := db.Exec(`ALTER TABLE lists ADD COLUMN IF NOT EXISTS "description" TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
// Add archive publishing field to campaigns.
if _, err := db.Exec(`ALTER TABLE campaigns
ADD COLUMN IF NOT EXISTS archive BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS archive_meta JSONB NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS archive_template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1
`); err != nil {
return err
}
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('app.site_name', '"Mailing list"'),
('app.enable_public_archive', 'true'),
('privacy.allow_preferences', 'false')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_4_0 performs the DB migrations.
func V2_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('security.enable_captcha', 'false'),
('security.captcha_key', '""'),
('security.captcha_secret', '""')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_5_0 performs the DB migrations.
func V2_5_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('upload.extensions', '["jpg","jpeg","png","gif","svg","*"]'),
('app.enable_public_archive_rss_content', 'false'),
('bounce.actions', '{"soft": {"count": 2, "action": "none"}, "hard": {"count": 2, "action": "blocklist"}, "complaint" : {"count": 2, "action": "blocklist"}}'),
('privacy.record_optin_ip', 'false')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
if _, err := db.Exec(`
DELETE FROM settings WHERE key IN ('bounce.count', 'bounce.action');
-- Add the content_type column.
ALTER TABLE media ADD COLUMN IF NOT EXISTS content_type TEXT NOT NULL DEFAULT 'application/octet-stream';
-- Add meta column to subscriptions.
ALTER TABLE subscriber_lists ADD COLUMN IF NOT EXISTS meta JSONB NOT NULL DEFAULT '{}';
-- Fill the content type column for existing files (which would only be images at this point).
UPDATE media SET content_type = CASE
WHEN LOWER(SUBSTRING(filename FROM '.([^.]+)$')) = 'svg' THEN 'image/svg+xml'
ELSE 'image/' || LOWER(SUBSTRING(filename FROM '.([^.]+)$'))
END;
`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS campaign_media (
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
-- Media items may be deleted, so media_id is nullable
-- and a copy of the original name is maintained here.
media_id INTEGER NULL REFERENCES media(id) ON DELETE SET NULL ON UPDATE CASCADE,
filename TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_camp_media_id ON campaign_media (campaign_id, media_id);
CREATE INDEX IF NOT EXISTS idx_camp_media_camp_id ON campaign_media(campaign_id);
`); err != nil {
return err
}
return nil
}
+137
View File
@@ -0,0 +1,137 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V3_0_0 performs the DB migrations.
func V3_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
lo.Println("IMPORTANT: this upgrade might take a while if you have a large database. Please be patient ...")
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('bounce.postmark', '{"enabled": false, "username": "", "password": ""}'),
('app.cache_slow_queries', 'false'),
('app.cache_slow_queries_interval', '"0 3 * * *"')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
// Fix incorrect "d" (day) time prefix in S3 expiry settings.
if _, err := db.Exec(`UPDATE settings SET value = '"167h"' WHERE key = 'upload.s3.expiry' AND value = '"14d"'`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS archive_slug TEXT NULL UNIQUE`); err != nil {
return err
}
// Add indexes that make sorting faster on large tables.
if _, err := db.Exec(`
CREATE INDEX IF NOT EXISTS idx_subs_created_at ON subscribers(created_at);
CREATE INDEX IF NOT EXISTS idx_subs_updated_at ON subscribers(updated_at);
CREATE INDEX IF NOT EXISTS idx_camps_status ON campaigns(status);
CREATE INDEX IF NOT EXISTS idx_camps_name ON campaigns(name);
CREATE INDEX IF NOT EXISTS idx_camps_created_at ON campaigns(created_at);
CREATE INDEX IF NOT EXISTS idx_camps_updated_at ON campaigns(updated_at);
CREATE INDEX IF NOT EXISTS idx_lists_type ON lists(type);
CREATE INDEX IF NOT EXISTS idx_lists_optin ON lists(optin);
CREATE INDEX IF NOT EXISTS idx_lists_name ON lists(name);
CREATE INDEX IF NOT EXISTS idx_lists_created_at ON lists(created_at);
CREATE INDEX IF NOT EXISTS idx_lists_updated_at ON lists(updated_at);
`); err != nil {
return err
}
// Create materialized views for slow aggregate queries.
if _, err := db.Exec(`
-- dashboard stats
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_dashboard_counts AS
WITH subs AS (
SELECT COUNT(*) AS num, status FROM subscribers GROUP BY status
)
SELECT NOW() AS updated_at,
JSON_BUILD_OBJECT(
'subscribers', JSON_BUILD_OBJECT(
'total', (SELECT SUM(num) FROM subs),
'blocklisted', (SELECT num FROM subs WHERE status='blocklisted'),
'orphans', (
SELECT COUNT(id) FROM subscribers
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
WHERE subscriber_lists.subscriber_id IS NULL
)
),
'lists', JSON_BUILD_OBJECT(
'total', (SELECT COUNT(*) FROM lists),
'private', (SELECT COUNT(*) FROM lists WHERE type='private'),
'public', (SELECT COUNT(*) FROM lists WHERE type='public'),
'optin_single', (SELECT COUNT(*) FROM lists WHERE optin='single'),
'optin_double', (SELECT COUNT(*) FROM lists WHERE optin='double')
),
'campaigns', JSON_BUILD_OBJECT(
'total', (SELECT COUNT(*) FROM campaigns),
'by_status', (
SELECT JSON_OBJECT_AGG (status, num) FROM
(SELECT status, COUNT(*) AS num FROM campaigns GROUP BY status) r
)
),
'messages', (SELECT SUM(sent) AS messages FROM campaigns)
) AS data;
CREATE UNIQUE INDEX IF NOT EXISTS mat_dashboard_stats_idx ON mat_dashboard_counts (updated_at);
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_dashboard_charts AS
WITH clicks AS (
SELECT JSON_AGG(ROW_TO_JSON(row))
FROM (
WITH viewDates AS (
SELECT TIMEZONE('UTC', created_at)::DATE AS to_date,
TIMEZONE('UTC', created_at)::DATE - INTERVAL '30 DAY' AS from_date
FROM link_clicks ORDER BY id DESC LIMIT 1
)
SELECT COUNT(*) AS count, created_at::DATE as date FROM link_clicks
-- use > between < to force the use of the date index.
WHERE TIMEZONE('UTC', created_at)::DATE BETWEEN (SELECT from_date FROM viewDates) AND (SELECT to_date FROM viewDates)
GROUP by date ORDER BY date
) row
),
views AS (
SELECT JSON_AGG(ROW_TO_JSON(row))
FROM (
WITH viewDates AS (
SELECT TIMEZONE('UTC', created_at)::DATE AS to_date,
TIMEZONE('UTC', created_at)::DATE - INTERVAL '30 DAY' AS from_date
FROM campaign_views ORDER BY id DESC LIMIT 1
)
SELECT COUNT(*) AS count, created_at::DATE as date FROM campaign_views
-- use > between < to force the use of the date index.
WHERE TIMEZONE('UTC', created_at)::DATE BETWEEN (SELECT from_date FROM viewDates) AND (SELECT to_date FROM viewDates)
GROUP by date ORDER BY date
) row
)
SELECT NOW() AS updated_at, JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
'campaign_views', COALESCE((SELECT * FROM views), '[]')
) AS data;
CREATE UNIQUE INDEX IF NOT EXISTS mat_dashboard_charts_idx ON mat_dashboard_charts (updated_at);
-- subscriber counts stats for lists
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_list_subscriber_stats AS
SELECT NOW() AS updated_at, lists.id AS list_id, subscriber_lists.status, COUNT(*) AS subscriber_count FROM lists
LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = lists.id)
GROUP BY lists.id, subscriber_lists.status
UNION ALL
SELECT NOW() AS updated_at, 0 AS list_id, NULL AS status, COUNT(*) AS subscriber_count FROM subscribers;
CREATE UNIQUE INDEX IF NOT EXISTS mat_list_subscriber_stats_idx ON mat_list_subscriber_stats (list_id, status);
`); err != nil {
return err
}
return nil
}
+144
View File
@@ -0,0 +1,144 @@
package migrations
import (
"encoding/json"
"log"
"os"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
)
// V4_0_0 performs the DB migrations.
func V4_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
lo.Println("IMPORTANT: this upgrade might take a while if you have a large database. Please be patient ...")
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_subs_id_status ON subscribers(id, status);`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type') THEN
CREATE TYPE user_type AS ENUM ('user', 'api');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status') THEN
CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
CREATE TYPE role_type AS ENUM ('user', 'list');
END IF;
END$$;
CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY,
type role_type NOT NULL DEFAULT 'user',
parent_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
permissions TEXT[] NOT NULL DEFAULT '{}',
name TEXT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles ON roles (parent_id, list_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_name ON roles (type, name) WHERE name IS NOT NULL;
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_login BOOLEAN NOT NULL DEFAULT false,
password TEXT NULL,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
avatar TEXT NULL,
type user_type NOT NULL DEFAULT 'user',
user_role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
list_role_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE,
status user_status NOT NULL DEFAULT 'disabled',
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT NOT NULL PRIMARY KEY,
data JSONB DEFAULT '{}'::jsonb NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions ON sessions (id, created_at);
`); err != nil {
return err
}
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES('security.oidc', '{"enabled": false, "provider_url": "", "client_id": "", "client_secret": ""}') ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
// Insert superuser role.
pmRaw, err := fs.Read("/permissions.json")
if err != nil {
lo.Fatalf("error reading permissions file: %v", err)
}
permGroups := []struct {
Group string `json:"group"`
Permissions []string `json:"permissions"`
}{}
if err := json.Unmarshal(pmRaw, &permGroups); err != nil {
lo.Fatalf("error loading permissions file: %v", err)
}
// Create super admin.
var (
user = os.Getenv("EAGLECAST_ADMIN_USER")
password = os.Getenv("EAGLECAST_ADMIN_PASSWORD")
typ = "env"
)
if user != "" {
// If the env vars are set, use those values
if len(user) < 2 || len(password) < 8 {
lo.Fatal("EAGLECAST_ADMIN_USER should be min 3 chars and EAGLECAST_ADMIN_PASSWORD should be min 8 chars")
}
} else if ko.Exists("app.admin_username") {
// Legacy admin/password are set in the config or env var. Use those.
user = ko.String("app.admin_username")
password = ko.String("app.admin_password")
if len(user) < 2 || len(password) < 8 {
lo.Fatal("admin_username should be min 3 chars and admin_password should be min 8 chars in the TOML config")
}
typ = "TOML config"
}
if user != "" && password != "" {
lo.Printf("creating admin user '%s'. Credential source is '%s'", user, typ)
perms := []string{}
for _, group := range permGroups {
perms = append(perms, group.Permissions...)
}
if _, err := db.Exec(`INSERT INTO roles (type, name, permissions) VALUES('user', 'Super Admin', $1) ON CONFLICT DO NOTHING`, pq.Array(perms)); err != nil {
return err
}
if _, err := db.Exec(`
INSERT INTO users (username, password_login, password, email, name, type, user_role_id, status) VALUES($1, true, CRYPT($2, GEN_SALT('bf')), $3, $4, 'user', 1, 'enabled') ON CONFLICT DO NOTHING;
`, user, password, user+"@eaglecast", user); err != nil {
return err
}
} else {
lo.Printf("no Super Admin user created. Visit webpage to create user.")
}
return nil
}
+21
View File
@@ -0,0 +1,21 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V4_1_0 performs the DB migrations.
func V4_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES('bounce.forwardemail', '{"enabled": false, "key": ""}') ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V5_0_0 performs the DB migrations.
func V5_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
lo.Println("IMPORTANT: this upgrade might take a while if you have a large database. Please be patient ...")
if _, err := db.Exec(`
-- Create a new temp materialized view with the fixed query (removing COUNT(*) that returns 1 for NULLs)
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_list_subscriber_stats_v5_0_0 AS
SELECT NOW() AS updated_at, lists.id AS list_id, subscriber_lists.status, COUNT(subscriber_lists.status) AS subscriber_count FROM lists
LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = lists.id)
GROUP BY lists.id, subscriber_lists.status
UNION ALL
SELECT NOW() AS updated_at, 0 AS list_id, NULL AS status, COUNT(id) AS subscriber_count FROM subscribers;
-- Drop the old view and index.
DROP INDEX IF EXISTS mat_list_subscriber_stats_idx;
DROP MATERIALIZED VIEW IF EXISTS mat_list_subscriber_stats;
-- Rename the temp view and create an index.
ALTER MATERIALIZED VIEW mat_list_subscriber_stats_v5_0_0 RENAME TO mat_list_subscriber_stats;
CREATE UNIQUE INDEX IF NOT EXISTS mat_list_subscriber_stats_idx ON mat_list_subscriber_stats (list_id, status);
`); err != nil {
return err
}
// Index of media filename lookup.
if _, err := db.Exec(`
CREATE INDEX IF NOT EXISTS idx_media_filename ON media(provider, filename);
`); err != nil {
return err
}
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('privacy.domain_allowlist', '[]'),
('security.oidc.provider_name', '""')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
// Insert new default super admin permissions.
if _, err := db.Exec(`
UPDATE roles SET permissions = permissions || '{campaigns:get_all}' WHERE id = 1 AND NOT permissions @> '{campaigns:get_all}';
UPDATE roles SET permissions = permissions || '{campaigns:manage_all}' WHERE id = 1 AND NOT permissions @> '{campaigns:manage_all}';
`); err != nil {
return err
}
// Visual editor changes.
if _, err := db.Exec(`
ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'visual';
ALTER TYPE template_type ADD VALUE IF NOT EXISTS 'campaign_visual';
ALTER TABLE templates ADD COLUMN IF NOT EXISTS body_source TEXT NULL;
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS body_source TEXT NULL;
`); err != nil {
return err
}
if _, err := db.Exec(`
ALTER TABLE campaigns DROP CONSTRAINT IF EXISTS campaigns_template_id_fkey,
ADD FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE SET NULL,
ALTER COLUMN template_id DROP DEFAULT;
ALTER TABLE campaigns DROP CONSTRAINT IF EXISTS campaigns_archive_template_id_fkey,
ADD FOREIGN KEY (archive_template_id) REFERENCES templates(id) ON DELETE SET NULL,
ALTER COLUMN archive_template_id DROP DEFAULT;
`); err != nil {
return err
}
// Insert visual campaign template.
visualTpl, err := fs.Get("/static/email-templates/default-visual.tpl")
if err != nil {
lo.Fatalf("error reading default visual template: %v", err)
}
visualSrc, err := fs.Get("/static/email-templates/default-visual.json")
if err != nil {
lo.Fatalf("error reading default visual template json: %v", err)
}
if _, err := db.Exec(`INSERT INTO templates (name, type, subject, body, body_source) VALUES($1, $2, $3, $4, $5)`,
"Sample visual template", "campaign_visual", "", visualTpl.ReadBytes(), visualSrc.ReadBytes()); err != nil {
return err
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
func V5_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Update OIDC settings to include auto_create_users and default_user_role_id fields if not present
_, err := db.Exec(`
UPDATE settings
SET value = value::JSONB
|| CASE WHEN NOT (value::JSONB ? 'auto_create_users') THEN '{"auto_create_users": false}'::JSONB ELSE '{}'::JSONB END
|| CASE WHEN NOT (value::JSONB ? 'default_user_role_id') THEN '{"default_user_role_id": null}'::JSONB ELSE '{}'::JSONB END
|| CASE WHEN NOT (value::JSONB ? 'default_list_role_id') THEN '{"default_list_role_id": null}'::JSONB ELSE '{}'::JSONB END
WHERE key = 'security.oidc';
`)
if err != nil {
return err
}
// Migrate old captcha settings to new JSON structure.
_, err = db.Exec(`
WITH old AS (
SELECT
COALESCE((SELECT (value#>>'{}')::BOOLEAN FROM settings WHERE key = 'security.enable_captcha'), false) AS enable_captcha,
COALESCE((SELECT value#>>'{}' FROM settings WHERE key = 'security.captcha_key'), '') AS captcha_key,
COALESCE((SELECT value#>>'{}' FROM settings WHERE key = 'security.captcha_secret'), '') AS captcha_secret
)
INSERT INTO settings (key, value, updated_at)
SELECT
'security.captcha',
JSON_BUILD_OBJECT(
'altcha', JSON_BUILD_OBJECT('enabled', false, 'complexity', 300000),
'hcaptcha', JSON_BUILD_OBJECT('enabled', enable_captcha, 'key', captcha_key, 'secret', captcha_secret)
),
NOW()
FROM old
ON CONFLICT (key) DO NOTHING
`)
if err != nil {
return err
}
// Remove old captcha settings.
if _, err = db.Exec(`DELETE FROM settings WHERE key IN ('security.enable_captcha', 'security.captcha_key', 'security.captcha_secret')`); err != nil {
return err
}
// Add maintenance.db setting if not present.
_, err = db.Exec(`
INSERT INTO settings (key, value, updated_at)
VALUES ('maintenance.db', '{"vacuum": false, "vacuum_cron_interval": "0 2 * * *"}', NOW())
ON CONFLICT (key) DO NOTHING
`)
if err != nil {
return err
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
func V6_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
_, err := db.Exec(`
INSERT INTO settings (key, value, updated_at) VALUES ('security.cors_origins', '[]', NOW()) ON CONFLICT (key) DO NOTHING
`)
if err != nil {
return err
}
// Add 2FA fields to users table.
_, err = db.Exec(`
DO $$ BEGIN
-- Create twofa_type enum if it doesn't exist
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'twofa_type') THEN
CREATE TYPE twofa_type AS ENUM ('none', 'totp');
END IF;
END $$;
ALTER TABLE users ADD COLUMN IF NOT EXISTS twofa_type twofa_type NOT NULL DEFAULT 'none';
ALTER TABLE users ADD COLUMN IF NOT EXISTS twofa_key TEXT NULL;
`)
if err != nil {
return err
}
// Add status field to lists table.
_, err = db.Exec(`
DO $$ BEGIN
-- Create list_status enum if it doesn't exist
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'list_status') THEN
CREATE TYPE list_status AS ENUM ('active', 'archived');
END IF;
END $$;
ALTER TABLE lists ADD COLUMN IF NOT EXISTS status list_status NOT NULL DEFAULT 'active';
CREATE INDEX IF NOT EXISTS idx_lists_status ON lists(status);
`)
if err != nil {
return err
}
// Add attribs field to campaigns table.
_, err = db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS attribs JSONB NOT NULL DEFAULT '{}'`)
if err != nil {
return err
}
return nil
}
+86
View File
@@ -0,0 +1,86 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
func V6_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`
INSERT INTO settings (key, value, updated_at) VALUES ('privacy.disable_tracking', 'false', NOW()) ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES('bounce.lettermint', '{"enabled": false, "key": ""}') ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
// Drop the old UTC-based date indexes and simply use local time with zone consistent
// with the rest of the schema.
if _, err := db.Exec(`
DROP INDEX IF EXISTS idx_views_date; CREATE INDEX IF NOT EXISTS idx_views_date ON campaign_views(created_at);
DROP INDEX IF EXISTS idx_clicks_date; CREATE INDEX IF NOT EXISTS idx_clicks_date ON link_clicks(created_at);
DROP INDEX IF EXISTS idx_bounces_date; CREATE INDEX IF NOT EXISTS idx_bounces_date ON bounces(created_at);
`); err != nil {
return err
}
// Recreate the materialized views to use server local time instead of UTC.
// Create new views first, let them populate, then drop the old ones and rename the new ones.
lo.Println("IMPORTANT: recreating analytics materialized views. This might take a while if you have a large database. Please be patient ...")
if _, err := db.Exec(`
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_dashboard_charts_v6_1_0 AS
WITH clicks AS (
SELECT JSON_AGG(ROW_TO_JSON(row))
FROM (
WITH viewDates AS (
SELECT created_at::DATE AS to_date,
created_at::DATE - INTERVAL '30 DAY' AS from_date
FROM link_clicks ORDER BY id DESC LIMIT 1
)
SELECT COUNT(*) AS count, created_at::DATE as date FROM link_clicks
WHERE created_at >= (SELECT from_date FROM viewDates)
AND created_at < (SELECT to_date FROM viewDates) + INTERVAL '1 day'
GROUP by date ORDER BY date
) row
),
views AS (
SELECT JSON_AGG(ROW_TO_JSON(row))
FROM (
WITH viewDates AS (
SELECT created_at::DATE AS to_date,
created_at::DATE - INTERVAL '30 DAY' AS from_date
FROM campaign_views ORDER BY id DESC LIMIT 1
)
SELECT COUNT(*) AS count, created_at::DATE as date FROM campaign_views
WHERE created_at >= (SELECT from_date FROM viewDates)
AND created_at < (SELECT to_date FROM viewDates) + INTERVAL '1 day'
GROUP by date ORDER BY date
) row
)
SELECT NOW() AS updated_at, JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
'campaign_views', COALESCE((SELECT * FROM views), '[]')
) AS data;
DROP INDEX IF EXISTS mat_dashboard_charts_idx;
DROP MATERIALIZED VIEW IF EXISTS mat_dashboard_charts;
ALTER MATERIALIZED VIEW mat_dashboard_charts_v6_1_0 RENAME TO mat_dashboard_charts;
CREATE UNIQUE INDEX IF NOT EXISTS mat_dashboard_charts_idx ON mat_dashboard_charts (updated_at);
`); err != nil {
return err
}
// Grant 'campaigns:send' to all roles that previously had 'campaigns:manage' or 'campaigns:manage_all'
// for backwards compatibility. 'campaigns:send' is a new separate permission.
if _, err := db.Exec(`
UPDATE roles SET permissions = permissions || '{campaigns:send}'
WHERE (permissions @> '{campaigns:manage}' OR permissions @> '{campaigns:manage_all}')
AND NOT permissions @> '{campaigns:send}';
`); err != nil {
return err
}
return nil
}
+91
View File
@@ -0,0 +1,91 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
func V6_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Add `msg_retry_delay` and `from_addresses` to each SMTP server entry in the `smtp`
// Idempotent: only updates rows where at least one entry is missing the key.
if _, err := db.Exec(`
UPDATE settings
SET value = (
SELECT JSONB_AGG(
JSONB_SET(
JSONB_SET(
v,
'{msg_retry_delay}',
COALESCE(v->'msg_retry_delay', '"10ms"'::JSONB)
),
'{from_addresses}',
COALESCE(v->'from_addresses', '[]'::JSONB)
)
ORDER BY ord
)
FROM JSONB_ARRAY_ELEMENTS(value) WITH ORDINALITY AS t(v, ord)
)
WHERE key = 'smtp'
AND EXISTS (
SELECT 1 FROM JSONB_ARRAY_ELEMENTS(value) AS v
WHERE NOT (v ? 'msg_retry_delay') OR NOT (v ? 'from_addresses')
);
`); err != nil {
return err
}
// Update app language settings that used incorrect locale codes.
if _, err := db.Exec(`
UPDATE settings SET value = langs.new_value
FROM (VALUES
('"cs-cz"'::JSONB, '"cs"'::JSONB),
('"jp"'::JSONB, '"ja"'::JSONB),
('"se"'::JSONB, '"sv"'::JSONB)
) AS langs(old_value, new_value)
WHERE key = 'app.lang' AND value = langs.old_value;
`); err != nil {
return err
}
// Add `bounce.azure` for ACS/Event Grid bounce handling; upsert if the row already exists.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES('bounce.azure', '{"enabled": false, "shared_secret": "", "shared_secret_header": ""}')
ON CONFLICT (key) DO UPDATE
SET value = jsonb_build_object(
'enabled', COALESCE((settings.value->>'enabled')::boolean, false),
'shared_secret', COALESCE(settings.value->>'shared_secret', ''),
'shared_secret_header', COALESCE(settings.value->>'shared_secret_header', '')
);
`); err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO settings (key, value) VALUES ('app.show_optin_page', 'true') ON CONFLICT (key) DO NOTHING `); err != nil {
return err
}
// Rename `security.cors_origins` to `security.trusted_urls`.
if _, err := db.Exec(`UPDATE settings SET key = 'security.trusted_urls'
WHERE key = 'security.cors_origins'
AND NOT EXISTS (SELECT 1 FROM settings WHERE key = 'security.trusted_urls')`); err != nil {
return err
}
// Hash existing API tokens. This is idempotent by skipping values that
// already look like lowercase SHA-256 hex digests.
if _, err := db.Exec(`
UPDATE users
SET password = ENCODE(DIGEST(password, 'sha256'), 'hex')
WHERE type = 'api'
AND password IS NOT NULL
AND password != ''
AND password !~ '^[a-f0-9]{64}$';
`); err != nil {
return err
}
return nil
}