@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user