HEAVY: migrate accounts and logs to service/repo architecture

This commit is contained in:
ari melody 2026-07-31 02:43:45 +01:00
parent 5c255fb34b
commit 49e14b5bc5
Signed by: ari
GPG key ID: CF99829C92678188
37 changed files with 1019 additions and 687 deletions

View file

@ -0,0 +1,33 @@
package account
import "arimelody-web/model"
type AccountRepository interface {
GetAll() ([]model.Account, error)
GetCount() (int, error)
GetByID(id string) (*model.Account, error)
GetByUsername(username string) (*model.Account, error)
GetByEmail(email string) (*model.Account, error)
GetBySession(sessionToken string) (*model.Account, error)
// Create an account, returning the new account ID.
Create(username string, password string, email *string, avatarURL *string) (string, error)
// Intended for large profile updates. For smaller adjusments,
// more specialised Change* and Remove* functions should be used.
Update(id string, username string, password string, email *string, avatarUrl *string) error
ChangeUsername(id string, username string) error
ChangePassword(id string, password string) error
ChangeEmail(id string, email string) error
RemoveEmail(id string) error
ChangeAvatarURL(id string, avatarURL string) error
RemoveAvatar(id string) error
Delete(accountID string) error
// Increment the number of account login failure attempts,
// returning the current fail count.
IncrementFails(accountID string) (int, error)
Lock(accountID string) error
Unlock(accountID string) error
}

View file

@ -0,0 +1,199 @@
package account
import (
"arimelody-web/model"
"strings"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type (
AccountRepositoryPostgres struct {
db *sqlx.DB
}
)
var _ AccountRepository = new(AccountRepositoryPostgres)
func NewAccountRepositoryPostgres(db *sqlx.DB) *AccountRepositoryPostgres {
return &AccountRepositoryPostgres{ db: db }
}
func (repo *AccountRepositoryPostgres) GetAll() ([]model.Account, error) {
var accounts = []model.Account{}
err := repo.db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC")
if err != nil {
return nil, err
}
return accounts, nil
}
func (repo *AccountRepositoryPostgres) GetCount() (int, error) {
accountsCount := 0
err := repo.db.Get(&accountsCount, "SELECT count(*) FROM account")
return accountsCount, err
}
func (repo *AccountRepositoryPostgres) GetByID(id string) (*model.Account, error) {
var account = model.Account{}
err := repo.db.Get(&account, "SELECT * FROM account WHERE id=$1", id)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func (repo *AccountRepositoryPostgres) GetByUsername(username string) (*model.Account, error) {
var account = model.Account{}
err := repo.db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func (repo *AccountRepositoryPostgres) GetByEmail(email string) (*model.Account, error) {
var account = model.Account{}
err := repo.db.Get(&account, "SELECT * FROM account WHERE email=$1", email)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func (repo *AccountRepositoryPostgres) GetBySession(sessionToken string) (*model.Account, error) {
if sessionToken == "" { return nil, nil }
account := model.Account{}
err := repo.db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func (repo *AccountRepositoryPostgres) Create(
username string,
password string,
email *string,
avatarURL *string,
) (string, error) {
var id string
err := repo.db.Get(
&id,
"INSERT INTO account (username, password, email, avatar_url) " +
"VALUES ($1, $2, $3, $4) " +
"RETURNING id",
username,
password,
email,
avatarURL,
)
return id, err
}
func (repo *AccountRepositoryPostgres) Update(
id string,
username string,
password string,
email *string,
avatarURL *string,
) error {
_, err := repo.db.Exec(
"UPDATE account " +
"SET username=$2,password=$3,email=$4,avatar_url=$5 " +
"WHERE id=$1",
id,
username,
password,
email,
avatarURL,
)
return err
}
func (repo *AccountRepositoryPostgres) ChangeUsername(id string, username string) error {
_, err := repo.db.Exec(
"UPDATE account SET username=$2 WHERE id=$1",
id, username,
)
return err
}
func (repo *AccountRepositoryPostgres) ChangePassword(id string, password string) error {
_, err := repo.db.Exec(
"UPDATE account SET password=$2 WHERE id=$1",
id, password,
)
return err
}
func (repo *AccountRepositoryPostgres) ChangeEmail(id string, email string) error {
_, err := repo.db.Exec(
"UPDATE account SET email=$2 WHERE id=$1",
id, email,
)
return err
}
func (repo *AccountRepositoryPostgres) RemoveEmail(id string) error {
_, err := repo.db.Exec("UPDATE account SET email=NULL WHERE id=$1", id)
return err
}
func (repo *AccountRepositoryPostgres) ChangeAvatarURL(id string, avatarURL string) error {
_, err := repo.db.Exec(
"UPDATE account SET avatar_url=$2 WHERE id=$1",
id, avatarURL,
)
return err
}
func (repo *AccountRepositoryPostgres) RemoveAvatar(id string) error {
_, err := repo.db.Exec("UPDATE account SET avatar_url=NULL WHERE id=$1", id)
return err
}
func (repo *AccountRepositoryPostgres) Delete(accountID string) error {
_, err := repo.db.Exec("DELETE FROM account WHERE id=$1", accountID)
return err
}
// Increment the number of account login failure attempts,
// returning the current fail count.
func (repo *AccountRepositoryPostgres) IncrementFails(accountID string) (int, error) {
failAttempts := 0
err := repo.db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID)
return failAttempts, err
}
func (repo *AccountRepositoryPostgres) Lock(accountID string) error {
_, err := repo.db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID)
return err
}
func (repo *AccountRepositoryPostgres) Unlock(accountID string) error {
_, err := repo.db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID)
return err
}

View file

@ -0,0 +1,9 @@
package log
import "arimelody-web/model"
type LogRepository interface {
Create(logLevel model.LogLevel, logType string, content string) error
Get(id string) (*model.Log, error)
Search(levelFilters []model.LogLevel, typeFilters []string, content string, limit int, offset int) ([]*model.Log, error)
}

110
repository/log/postgres.go Normal file
View file

@ -0,0 +1,110 @@
package log
import (
"arimelody-web/model"
"fmt"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type (
LogRepositoryPostgres struct {
db *sqlx.DB
}
)
var _ LogRepository = new(LogRepositoryPostgres)
func NewLogRepositoryPostgres(db *sqlx.DB) *LogRepositoryPostgres {
return &LogRepositoryPostgres{ db: db }
}
func (repo *LogRepositoryPostgres) Create(logLevel model.LogLevel, logType string, content string) error {
_, err := repo.db.Exec(
"INSERT INTO auditlog (level, type, content) VALUES ($1,$2,$3)",
logLevel,
logType,
content,
)
return err
}
func (repo *LogRepositoryPostgres) Get(id string) (*model.Log, error) {
log := model.Log{}
err := repo.db.Get(&log, "SELECT * FROM auditlog WHERE id=$1", id)
return &log, err
}
func (repo *LogRepositoryPostgres) Search(
levelFilters []model.LogLevel,
typeFilters []string,
content string,
limit int,
offset int,
) ([]*model.Log, error) {
logs := []*model.Log{}
params := []any{ limit, offset }
conditions := ""
if len(content) > 0 {
content = "%" + content + "%"
conditions += " WHERE content LIKE $3"
params = append(params, content)
}
if len(levelFilters) > 0 {
if len(conditions) > 0 {
conditions += " AND level IN ("
} else {
conditions += " WHERE level IN ("
}
for i := range levelFilters {
conditions += fmt.Sprintf("$%d", len(params) + 1)
if i < len(levelFilters) - 1 {
conditions += ","
}
params = append(params, levelFilters[i])
}
conditions += ")"
}
if len(typeFilters) > 0 {
if len(conditions) > 0 {
conditions += " AND type IN ("
} else {
conditions += " WHERE type IN ("
}
for i := range typeFilters {
conditions += fmt.Sprintf("$%d", len(params) + 1)
if i < len(typeFilters) - 1 {
conditions += ","
}
params = append(params, typeFilters[i])
}
conditions += ")"
}
query := fmt.Sprintf(
"SELECT * FROM auditlog%s ORDER BY created_at DESC LIMIT $1 OFFSET $2",
conditions,
)
/*
fmt.Printf("%s (", query)
for i, param := range params {
fmt.Print(param)
if i < len(params) - 1 {
fmt.Print(", ")
}
}
fmt.Print(")\n")
*/
err := repo.db.Select(&logs, query, params...)
if err != nil {
return nil, err
}
return logs, nil
}

View file

@ -0,0 +1,102 @@
package controller
import (
"embed"
"fmt"
"os"
"time"
"github.com/jmoiron/sqlx"
)
const DB_VERSION int = 4
func CheckDBVersionAndMigrate(db *sqlx.DB) {
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
db.MustExec("SET search_path TO arimelody, public")
db.MustExec(
"CREATE TABLE IF NOT EXISTS arimelody.schema_version (" +
"version INTEGER PRIMARY KEY, " +
"applied_at TIMESTAMP DEFAULT current_timestamp)",
)
oldDBVersion := 0
schemaVersionCount := 0
err := db.Get(&schemaVersionCount, "SELECT COUNT(*) FROM schema_version")
if err != nil { panic(err) }
if schemaVersionCount > 0 {
err := db.Get(&oldDBVersion, "SELECT MAX(version) FROM schema_version")
if err != nil { panic(err) }
}
for oldDBVersion < DB_VERSION {
switch oldDBVersion {
case 0:
// default case; assume no database exists
ApplyMigration(db, "000-init")
oldDBVersion = DB_VERSION
case 1:
// the irony is i actually have to awkwardly shove schema_version
// into the old database in order for this to work LOL
ApplyMigration(db, "001-pre-versioning")
oldDBVersion = 2
case 2:
ApplyMigration(db, "002-audit-logs")
oldDBVersion = 3
case 3:
ApplyMigration(db, "003-fail-lock")
oldDBVersion = 4
}
}
fmt.Printf("Database schema up to date.\n")
}
//go:embed "schema-migration"
var schemaFS embed.FS
func ApplyMigration(db *sqlx.DB, scriptFile string) {
fmt.Printf("Applying schema migration %s...\n", scriptFile)
bytes, err := schemaFS.ReadFile("schema-migration/" + scriptFile + ".sql")
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err)
os.Exit(1)
}
script := string(bytes)
tx, err := db.Begin()
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to begin migration: %v\n", err)
os.Exit(1)
}
_, err = tx.Exec(script)
if err != nil {
tx.Rollback()
fmt.Fprintf(os.Stderr, "FATAL: Failed to apply migration: %v\n", err)
os.Exit(1)
}
_, err = tx.Exec(
"INSERT INTO schema_version (version, applied_at) " +
"VALUES ($1, $2)",
DB_VERSION,
time.Now(),
)
if err != nil {
tx.Rollback()
fmt.Fprintf(os.Stderr, "FATAL: Failed to update schema version: %v\n", err)
os.Exit(1)
}
err = tx.Commit()
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to commit transaction: %v\n", err)
os.Exit(1)
}
}

View file

@ -0,0 +1,143 @@
--
-- Tables
--
-- Audit logs
CREATE TABLE arimelody.auditlog (
id UUID DEFAULT gen_random_uuid(),
level int NOT NULL DEFAULT 0,
type TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);
-- Accounts
CREATE TABLE arimelody.account (
id UUID DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT,
avatar_url TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
fail_attempts INT NOT NULL DEFAULT 0,
locked BOOLEAN DEFAULT false
);
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
-- Privilege
CREATE TABLE arimelody.privilege (
account UUID NOT NULL,
privilege TEXT NOT NULL
);
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
-- Invites
CREATE TABLE arimelody.invite (
code text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP NOT NULL
);
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- Sessions
CREATE TABLE arimelody.session (
token TEXT,
user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL,
account UUID,
attempt_account UUID,
message TEXT,
error TEXT
);
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
-- TOTP methods
CREATE TABLE arimelody.totp (
name TEXT NOT NULL,
account UUID NOT NULL,
secret TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
confirmed BOOLEAN DEFAULT false
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
-- Artists (should be applicable to all art)
CREATE TABLE arimelody.artist (
id character varying(64),
name text NOT NULL,
website text,
avatar text
);
ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id);
-- Music releases
CREATE TABLE arimelody.musicrelease (
id character varying(64) NOT NULL,
visible bool DEFAULT false,
title text NOT NULL,
description text,
type text,
release_date TIMESTAMP NOT NULL,
artwork text,
buyname text,
buylink text,
copyright text,
copyrightURL text,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
-- Music links (external platform links under a release)
CREATE TABLE arimelody.musiclink (
release character varying(64) NOT NULL,
name text NOT NULL,
url text NOT NULL
);
ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name);
-- Music credits (artist credits under a release)
CREATE TABLE arimelody.musiccredit (
release character varying(64) NOT NULL,
artist character varying(64) NOT NULL,
role text NOT NULL,
is_primary boolean DEFAULT false
);
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist);
-- Music tracks (tracks under a release)
CREATE TABLE arimelody.musictrack (
id uuid DEFAULT gen_random_uuid(),
title text NOT NULL,
description text,
lyrics text,
preview_url text
);
ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id);
-- Music release/track pairs
CREATE TABLE arimelody.musicreleasetrack (
release character varying(64) NOT NULL,
track uuid NOT NULL,
number integer NOT NULL
);
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track);
--
-- Foreign keys
--
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_attempt_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;
ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE;
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_trackref_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_releaseref_fk FOREIGN KEY (track) REFERENCES musictrack(id) ON DELETE CASCADE;

View file

@ -0,0 +1,58 @@
--
-- New items
--
-- Accounts
CREATE TABLE arimelody.account (
id UUID DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT,
avatar_url TEXT,
created_at TIMESTAMP DEFAULT current_timestamp
);
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
-- Privilege
CREATE TABLE arimelody.privilege (
account UUID NOT NULL,
privilege TEXT NOT NULL
);
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
-- Invites
CREATE TABLE arimelody.invite (
code text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP NOT NULL
);
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- Sessions
CREATE TABLE arimelody.session (
token TEXT,
user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL,
account UUID,
attempt_account UUID,
message TEXT,
error TEXT
);
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
-- TOTP methods
CREATE TABLE arimelody.totp (
name TEXT NOT NULL,
account UUID NOT NULL,
secret TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
confirmed BOOLEAN DEFAULT false
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
-- Foreign keys
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_attempt_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;

View file

@ -0,0 +1,12 @@
-- Audit logs
CREATE TABLE arimelody.auditlog (
id UUID DEFAULT gen_random_uuid(),
level int NOT NULL DEFAULT 0,
type TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);
-- Need moar timestamps
ALTER TABLE arimelody.musicrelease ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT current_timestamp;
ALTER TABLE arimelody.account ALTER COLUMN created_at SET NOT NULL;

View file

@ -0,0 +1,3 @@
-- it would be nice to prevent brute-forcing
ALTER TABLE arimelody.account ADD COLUMN fail_attempts INT NOT NULL DEFAULT 0;
ALTER TABLE arimelody.account ADD COLUMN locked BOOLEAN DEFAULT false;