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

@ -1,135 +0,0 @@
package controller
import (
"arimelody-web/model"
"strings"
"github.com/jmoiron/sqlx"
)
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
var accounts = []model.Account{}
err := db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC")
if err != nil {
return nil, err
}
return accounts, nil
}
func GetAccountByID(db *sqlx.DB, id string) (*model.Account, error) {
var account = model.Account{}
err := 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 GetAccountByUsername(db *sqlx.DB, username string) (*model.Account, error) {
var account = model.Account{}
err := 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 GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
var account = model.Account{}
err := 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 GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, error) {
if sessionToken == "" { return nil, nil }
account := model.Account{}
err := 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 CreateAccount(db *sqlx.DB, account *model.Account) error {
err := db.Get(
&account.ID,
"INSERT INTO account (username, password, email, avatar_url) " +
"VALUES ($1, $2, $3, $4) " +
"RETURNING id",
account.Username,
account.Password,
account.Email,
account.AvatarURL,
)
return err
}
func UpdateAccount(db *sqlx.DB, account *model.Account) error {
_, err := db.Exec(
"UPDATE account " +
"SET username=$2,password=$3,email=$4,avatar_url=$5 " +
"WHERE id=$1",
account.ID,
account.Username,
account.Password,
account.Email,
account.AvatarURL,
)
return err
}
func DeleteAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
return err
}
func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) {
failAttempts := 0
err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID)
if err != nil { return false, err }
locked := false
if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS {
err = LockAccount(db, accountID)
if err != nil { return false, err }
locked = true
}
return locked, err
}
func LockAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID)
return err
}
func UnlockAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID)
return err
}

View file

@ -1,28 +1,28 @@
package controller
import (
"errors"
"fmt"
"os"
"strconv"
"errors"
"fmt"
"os"
"strconv"
"arimelody-web/model"
"arimelody-web/model/app"
"github.com/pelletier/go-toml/v2"
"github.com/pelletier/go-toml/v2"
)
func GetConfig() model.Config {
func GetConfig() app.Config {
configFile := os.Getenv("ARIMELODY_CONFIG")
if configFile == "" {
configFile = "config.toml"
}
config := model.Config{
config := app.Config{
BaseUrl: "https://arimelody.space",
Host: "0.0.0.0",
Port: 8080,
TrustedProxies: []string{ "127.0.0.1" },
DB: model.DBConfig{
DB: app.DBConfig{
Host: "127.0.0.1",
Port: 5432,
User: "arimelody",
@ -53,7 +53,7 @@ func GetConfig() model.Config {
return config
}
func handleConfigOverrides(config *model.Config) error {
func handleConfigOverrides(config *app.Config) error {
var err error
if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env }

View file

@ -1,15 +1,15 @@
package controller
import (
"arimelody-web/model"
"net/http"
"slices"
"strings"
"arimelody-web/model/app"
"net/http"
"slices"
"strings"
)
// Returns the request's original IP address, resolving the `x-forwarded-for`
// header if the request originates from a trusted proxy.
func ResolveIP(app *model.AppState, r *http.Request) string {
func ResolveIP(app *app.AppState, r *http.Request) string {
addr := strings.Split(r.RemoteAddr, ":")[0]
if slices.Contains(app.Config.TrustedProxies, addr) {
forwardedFor := r.Header.Get("x-forwarded-for")

View file

@ -1,102 +0,0 @@
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

@ -1,143 +0,0 @@
--
-- 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

@ -1,58 +0,0 @@
--
-- 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

@ -1,12 +0,0 @@
-- 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

@ -1,3 +0,0 @@
-- 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;

View file

@ -1,21 +1,21 @@
package controller
import (
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/model"
"arimelody-web/model/app"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
const TOKEN_LEN = 64
func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) {
func GetSessionFromRequest(app *app.AppState, r *http.Request) (*model.Session, error) {
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie {
return nil, fmt.Errorf("Failed to retrieve session cookie: %v", err)
@ -25,7 +25,7 @@ func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session
if sessionCookie != nil {
// fetch existing session
session, err = GetSession(app.DB, sessionCookie.Value)
session, err = GetSession(app, sessionCookie.Value)
if err != nil && !strings.Contains(err.Error(), "no rows") {
return nil, fmt.Errorf("Failed to retrieve session: %v", err)
@ -35,13 +35,13 @@ func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session
if session.UserAgent != r.UserAgent() {
msg := "Session user agent mismatch. A cookie may have been hijacked!"
if session.Account != nil {
account, _ := GetAccountByID(app.DB, session.Account.ID)
account, _ := app.AccountService.GetByID(session.Account.ID)
msg += " (Account \"" + account.Username + "\")"
}
app.Log.Warn(log.TYPE_ACCOUNT, msg)
app.Log.Warn(model.LOG_ACCOUNT, msg)
err = DeleteSession(app.DB, session.Token)
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session")
app.Log.Warn(model.LOG_ACCOUNT, "Failed to delete affected session")
}
return nil, nil
}
@ -137,7 +137,7 @@ func SetSessionError(db *sqlx.DB, session *model.Session, message string) error
return err
}
func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
func GetSession(app *app.AppState, token string) (*model.Session, error) {
type dbSession struct {
model.Session
AttemptAccountID sql.NullString `db:"attempt_account"`
@ -145,7 +145,7 @@ func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
}
session := dbSession{}
err := db.Get(
err := app.DB.Get(
&session,
"SELECT * FROM session WHERE token=$1",
token,
@ -155,14 +155,14 @@ func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
}
if session.AccountID.Valid {
session.Account, err = GetAccountByID(db, session.AccountID.String)
session.Account, err = app.AccountService.GetByID(session.AccountID.String)
if err != nil {
return nil, err
}
}
if session.AttemptAccountID.Valid {
session.AttemptAccount, err = GetAccountByID(db, session.AttemptAccountID.String)
session.AttemptAccount, err = app.AccountService.GetByID(session.AttemptAccountID.String)
if err != nil {
return nil, err
}

View file

@ -1,7 +1,8 @@
package controller
import (
"arimelody-web/model"
"arimelody-web/model/app"
"arimelody-web/model/twitch"
"bytes"
"encoding/json"
"net/http"
@ -11,13 +12,13 @@ import (
const TWITCH_API_BASE = "https://api.twitch.tv/helix/"
func TwitchSetup(app *model.AppState) error {
app.Twitch = &model.TwitchState{}
func TwitchSetup(app *app.AppState) error {
app.Twitch = &twitch.State{}
err := RefreshTwitchToken(app)
return err
}
func RefreshTwitchToken(app *model.AppState) error {
func RefreshTwitchToken(app *app.AppState) error {
if app.Twitch != nil && app.Twitch.Token != nil && time.Now().UTC().After(app.Twitch.Token.ExpiresAt) {
return nil
}
@ -45,7 +46,7 @@ func RefreshTwitchToken(app *model.AppState) error {
return err
}
app.Twitch.Token = &model.TwitchOAuthToken{
app.Twitch.Token = &twitch.OAuthToken{
AccessToken: oauthResponse.AccessToken,
ExpiresAt: time.Now().UTC().Add(time.Second * time.Duration(oauthResponse.ExpiresIn)).UTC(),
TokenType: oauthResponse.TokenType,
@ -54,10 +55,10 @@ func RefreshTwitchToken(app *model.AppState) error {
return nil
}
var lastStreamState *model.TwitchStreamInfo
var lastStreamState *twitch.StreamInfo
var lastStreamStateAt time.Time
func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStreamInfo, error) {
func GetTwitchStatus(app *app.AppState, broadcaster string) (*twitch.StreamInfo, error) {
if lastStreamState != nil && time.Now().UTC().Before(lastStreamStateAt.Add(time.Minute)) {
return lastStreamState, nil
}
@ -76,7 +77,7 @@ func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStre
}
type StreamsResponse struct {
Data []model.TwitchStreamInfo `json:"data"`
Data []twitch.StreamInfo `json:"data"`
}
streamInfo := StreamsResponse{}
err = json.NewDecoder(res.Body).Decode(&streamInfo)