From 14350f84c55442a16f7d2657887e2723adec8593 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Thu, 16 Jul 2026 20:13:49 +0800 Subject: [PATCH] refactor: migrate backend database --- backend/database/database.go | 96 +++ backend/database/mysql_database.go | 143 ++++ backend/database/sqlite_database.go | 1138 +++++++++++++++++++++++++++ backend/go.mod | 1 + backend/go.sum | 2 + 5 files changed, 1380 insertions(+) create mode 100644 backend/database/database.go create mode 100644 backend/database/mysql_database.go create mode 100644 backend/database/sqlite_database.go diff --git a/backend/database/database.go b/backend/database/database.go new file mode 100644 index 0000000..a590bae --- /dev/null +++ b/backend/database/database.go @@ -0,0 +1,96 @@ +package database + +import ( + "context" + "log/slog" + + "github.com/yyc12345/coconut-leaf/backend/config" +) + +// ResponseBody is the envelope returned by every Database operation, mirroring +// the legacy database.ResponseBody. Data is any so each method can return its +// own shape (scalar, []string, []any row, [][]any rows, ...). JSON tags keep the +// legacy lowercase keys (success/error/data) used by ConstructResponseBody. +type ResponseBody struct { + Success bool `json:"success"` + Error string `json:"error"` + Data any `json:"data"` +} + +// CalendarUpdateOptions carries the optional fields of CalendarUpdate. A nil +// pointer means "not provided", mirroring the legacy **optArgs semantics: a +// field is applied only when its pointer is non-nil. +type CalendarUpdateOptions struct { + BelongTo *string + Title *string + Description *string + EventDateTimeStart *int64 + EventDateTimeEnd *int64 + LoopRules *string + TimezoneOffset *int64 +} + +// AdminUpdateOptions carries the optional fields of AdminUpdate. A nil pointer +// means "not provided". +type AdminUpdateOptions struct { + Password *string + IsAdmin *bool +} + +// Deps bundles the shared dependencies a database implementation needs: the +// loaded configuration and a logger. It is passed to database constructors and +// stored inside each implementation so they can read config and emit logs. +type Deps struct { + Cfg *config.Config + Logger *slog.Logger +} + +// Database describes every database operation the Gin server may use. It is the +// Go equivalent of the legacy CalendarDatabase public surface: each method takes +// a context.Context for cancellation and returns a ResponseBody whose +// Success/Error fields encode failure (mirroring the legacy decorator behavior). +type Database interface { + Init(username, password string) error + Close() error + + CommonSalt(ctx context.Context, username string) ResponseBody + CommonLogin(ctx context.Context, username, password, clientUa, clientIp string) ResponseBody + CommonWebLogin(ctx context.Context, username, password, clientUa, clientIp string) ResponseBody + CommonLogout(ctx context.Context, token string) ResponseBody + CommonTokenValid(ctx context.Context, token string) ResponseBody + + CalendarGetFull(ctx context.Context, token string, startDateTime, endDateTime int64) ResponseBody + CalendarGetList(ctx context.Context, token string, startDateTime, endDateTime int64) ResponseBody + CalendarGetDetail(ctx context.Context, token, uuid string) ResponseBody + CalendarUpdate(ctx context.Context, token, uuid, lastChange string, opts CalendarUpdateOptions) ResponseBody + CalendarAdd(ctx context.Context, token, belongTo, title, description string, eventDateTimeStart, eventDateTimeEnd int64, loopRules string, timezoneOffset int64) ResponseBody + CalendarDelete(ctx context.Context, token, uuid, lastChange string) ResponseBody + + CollectionGetFullOwn(ctx context.Context, token string) ResponseBody + CollectionGetListOwn(ctx context.Context, token string) ResponseBody + CollectionGetDetailOwn(ctx context.Context, token, uuid string) ResponseBody + CollectionAddOwn(ctx context.Context, token, name string) ResponseBody + CollectionUpdateOwn(ctx context.Context, token, uuid, name, lastChange string) ResponseBody + CollectionDeleteOwn(ctx context.Context, token, uuid, lastChange string) ResponseBody + CollectionGetSharing(ctx context.Context, token, uuid string) ResponseBody + CollectionDeleteSharing(ctx context.Context, token, uuid, target, lastChange string) ResponseBody + CollectionAddSharing(ctx context.Context, token, uuid, target, lastChange string) ResponseBody + CollectionGetShared(ctx context.Context, token string) ResponseBody + + TodoGetFull(ctx context.Context, token string) ResponseBody + TodoGetList(ctx context.Context, token string) ResponseBody + TodoGetDetail(ctx context.Context, token, uuid string) ResponseBody + TodoAdd(ctx context.Context, token string) ResponseBody + TodoUpdate(ctx context.Context, token, uuid, data, lastChange string) ResponseBody + TodoDelete(ctx context.Context, token, uuid, lastChange string) ResponseBody + + AdminGet(ctx context.Context, token string) ResponseBody + AdminAdd(ctx context.Context, token, username string) ResponseBody + AdminUpdate(ctx context.Context, token, username string, opts AdminUpdateOptions) ResponseBody + AdminDelete(ctx context.Context, token, username string) ResponseBody + + ProfileIsAdmin(ctx context.Context, token string) ResponseBody + ProfileChangePassword(ctx context.Context, token, password string) ResponseBody + ProfileGetToken(ctx context.Context, token string) ResponseBody + ProfileDeleteToken(ctx context.Context, token, deleteToken string) ResponseBody +} diff --git a/backend/database/mysql_database.go b/backend/database/mysql_database.go new file mode 100644 index 0000000..4a75dd0 --- /dev/null +++ b/backend/database/mysql_database.go @@ -0,0 +1,143 @@ +package database + +import ( + "context" + "errors" +) + +var errNotImplemented = errors.New("mysql database: not implemented") + +var notImplResp = ResponseBody{Success: false, Error: errNotImplemented.Error()} + +// stubDatabase provides a not-implemented default for every Database method. +// MysqlDatabase embeds it so the interface is satisfied without hand-writing +// each method; individual methods can be overridden on MysqlDatabase later. +type stubDatabase struct{} + +func (stubDatabase) Init(username, password string) error { return errNotImplemented } +func (stubDatabase) Close() error { return errNotImplemented } + +func (stubDatabase) CommonSalt(ctx context.Context, username string) ResponseBody { + return notImplResp +} +func (stubDatabase) CommonLogin(ctx context.Context, username, password, clientUa, clientIp string) ResponseBody { + return notImplResp +} +func (stubDatabase) CommonWebLogin(ctx context.Context, username, password, clientUa, clientIp string) ResponseBody { + return notImplResp +} +func (stubDatabase) CommonLogout(ctx context.Context, token string) ResponseBody { + return notImplResp +} +func (stubDatabase) CommonTokenValid(ctx context.Context, token string) ResponseBody { + return notImplResp +} + +func (stubDatabase) CalendarGetFull(ctx context.Context, token string, startDateTime, endDateTime int64) ResponseBody { + return notImplResp +} +func (stubDatabase) CalendarGetList(ctx context.Context, token string, startDateTime, endDateTime int64) ResponseBody { + return notImplResp +} +func (stubDatabase) CalendarGetDetail(ctx context.Context, token, uuid string) ResponseBody { + return notImplResp +} +func (stubDatabase) CalendarUpdate(ctx context.Context, token, uuid, lastChange string, opts CalendarUpdateOptions) ResponseBody { + return notImplResp +} +func (stubDatabase) CalendarAdd(ctx context.Context, token, belongTo, title, description string, eventDateTimeStart, eventDateTimeEnd int64, loopRules string, timezoneOffset int64) ResponseBody { + return notImplResp +} +func (stubDatabase) CalendarDelete(ctx context.Context, token, uuid, lastChange string) ResponseBody { + return notImplResp +} + +func (stubDatabase) CollectionGetFullOwn(ctx context.Context, token string) ResponseBody { + return notImplResp +} +func (stubDatabase) CollectionGetListOwn(ctx context.Context, token string) ResponseBody { + return notImplResp +} +func (stubDatabase) CollectionGetDetailOwn(ctx context.Context, token, uuid string) ResponseBody { + return notImplResp +} +func (stubDatabase) CollectionAddOwn(ctx context.Context, token, name string) ResponseBody { + return notImplResp +} +func (stubDatabase) CollectionUpdateOwn(ctx context.Context, token, uuid, name, lastChange string) ResponseBody { + return notImplResp +} +func (stubDatabase) CollectionDeleteOwn(ctx context.Context, token, uuid, lastChange string) ResponseBody { + return notImplResp +} +func (stubDatabase) CollectionGetSharing(ctx context.Context, token, uuid string) ResponseBody { + return notImplResp +} +func (stubDatabase) CollectionDeleteSharing(ctx context.Context, token, uuid, target, lastChange string) ResponseBody { + return notImplResp +} +func (stubDatabase) CollectionAddSharing(ctx context.Context, token, uuid, target, lastChange string) ResponseBody { + return notImplResp +} +func (stubDatabase) CollectionGetShared(ctx context.Context, token string) ResponseBody { + return notImplResp +} + +func (stubDatabase) TodoGetFull(ctx context.Context, token string) ResponseBody { + return notImplResp +} +func (stubDatabase) TodoGetList(ctx context.Context, token string) ResponseBody { + return notImplResp +} +func (stubDatabase) TodoGetDetail(ctx context.Context, token, uuid string) ResponseBody { + return notImplResp +} +func (stubDatabase) TodoAdd(ctx context.Context, token string) ResponseBody { + return notImplResp +} +func (stubDatabase) TodoUpdate(ctx context.Context, token, uuid, data, lastChange string) ResponseBody { + return notImplResp +} +func (stubDatabase) TodoDelete(ctx context.Context, token, uuid, lastChange string) ResponseBody { + return notImplResp +} + +func (stubDatabase) AdminGet(ctx context.Context, token string) ResponseBody { + return notImplResp +} +func (stubDatabase) AdminAdd(ctx context.Context, token, username string) ResponseBody { + return notImplResp +} +func (stubDatabase) AdminUpdate(ctx context.Context, token, username string, opts AdminUpdateOptions) ResponseBody { + return notImplResp +} +func (stubDatabase) AdminDelete(ctx context.Context, token, username string) ResponseBody { + return notImplResp +} + +func (stubDatabase) ProfileIsAdmin(ctx context.Context, token string) ResponseBody { + return notImplResp +} +func (stubDatabase) ProfileChangePassword(ctx context.Context, token, password string) ResponseBody { + return notImplResp +} +func (stubDatabase) ProfileGetToken(ctx context.Context, token string) ResponseBody { + return notImplResp +} +func (stubDatabase) ProfileDeleteToken(ctx context.Context, token, deleteToken string) ResponseBody { + return notImplResp +} + +// MysqlDatabase implements Database as a fully not-implemented stub by +// embedding stubDatabase. Real MySQL support can be filled in later by +// overriding individual methods. +type MysqlDatabase struct { + stubDatabase + deps Deps +} + +// NewMysqlDatabase returns a not-implemented MysqlDatabase. The deps are stored +// for symmetry with NewSqlite3Database but not yet used. +func NewMysqlDatabase(deps Deps) (*MysqlDatabase, error) { + return &MysqlDatabase{deps: deps}, nil +} diff --git a/backend/database/sqlite_database.go b/backend/database/sqlite_database.go new file mode 100644 index 0000000..3e9b4ef --- /dev/null +++ b/backend/database/sqlite_database.go @@ -0,0 +1,1138 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "strings" + "sync" + + _ "github.com/mattn/go-sqlite3" + + "github.com/yyc12345/coconut-leaf/backend/dt" + "github.com/yyc12345/coconut-leaf/backend/utils" +) + +// sqliteSchema is the DDL of the six tables, inlined from the legacy +// sql/sqlite.sql (ccn_-free snake_case column names). Each entry is executed as +// a single statement in Init. +var sqliteSchema = []string{ + `CREATE TABLE user( + [name] TEXT NOT NULL, + [password] TEXT NOT NULL, + [is_admin] TINYINT NOT NULL CHECK(is_admin = 1 OR is_admin = 0), + [salt] INTEGER NOT NULL, + + PRIMARY KEY (name) +);`, + `CREATE TABLE token( + [user] TEXT NOT NULL, + [token] TEXT UNIQUE NOT NULL, + [token_expire_on] BIGINT NOT NULL, + [ua] TEXT NOT NULL, + [ip] TEXT NOT NULL, + + FOREIGN KEY (user) REFERENCES user(name) ON DELETE CASCADE +);`, + `CREATE TABLE collection( + [uuid] TEXT NOT NULL, + [name] TEXT NOT NULL, + [user] TEXT NOT NULL, + [last_change] TEXT NOT NULL, + + PRIMARY KEY (uuid), + FOREIGN KEY (user) REFERENCES user(name) ON DELETE CASCADE +);`, + `CREATE TABLE share( + [uuid] TEXT NOT NULL, + [target] TEXT NOT NULL, + + FOREIGN KEY (uuid) REFERENCES collection(uuid) ON DELETE CASCADE + FOREIGN KEY (target) REFERENCES user(name) ON DELETE CASCADE +);`, + `CREATE TABLE calendar( + [uuid] TEXT NOT NULL, + [belong_to] TEXT NOT NULL, + + [title] TEXT NOT NULL, + [description] TEXT NOT NULL, + [last_change] TEXT NOT NULL, + + [event_date_time_start] BIGINT NOT NULL, + [event_date_time_end] BIGINT NOT NULL, + [timezone_offset] INT NOT NULL, + + [loop_rules] TEXT NOT NULL, + [loop_date_time_start] BIGINT NOT NULL, + [loop_date_time_end] BIGINT NOT NULL, + + PRIMARY KEY (uuid), + FOREIGN KEY (belong_to) REFERENCES collection(uuid) ON DELETE CASCADE +);`, + `CREATE TABLE todo( + [uuid] TEXT NOT NULL, + [belong_to] TEXT NOT NULL, + + [data] TEXT NOT NULL, + [last_change] TEXT NOT NULL, + + PRIMARY KEY (uuid), + FOREIGN KEY (belong_to) REFERENCES user(name) ON DELETE CASCADE +);`, +} + +// dbOp is the unit of database work executed inside a safeOp transaction. +type dbOp func(ctx context.Context, tx *sql.Tx) (any, error) + +// Sqlite3Database implements Database backed by a SQLite file. It owns the +// connection, a serializing mutex (SQLite is driven single-threaded here, as in +// the legacy code) and the auto token-clean bookkeeping. +type Sqlite3Database struct { + deps Deps + db *sql.DB + mu sync.Mutex + latestClean int +} + +// NewSqlite3Database opens the SQLite file and applies the legacy open() PRAGMAs +// (UTF-8 encoding, foreign keys). A single connection is used to mirror the +// legacy single-connection model and keep PRAGMAs effective. +func NewSqlite3Database(deps Deps) (*Sqlite3Database, error) { + db, err := sql.Open("sqlite3", deps.Cfg.Database.Sqlite.Path) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + if _, err := db.Exec(`PRAGMA encoding = "UTF-8";`); err != nil { + db.Close() + return nil, err + } + if _, err := db.Exec(`PRAGMA foreign_keys = ON;`); err != nil { + db.Close() + return nil, err + } + if err := db.Ping(); err != nil { + db.Close() + return nil, err + } + return &Sqlite3Database{deps: deps, db: db}, nil +} + +// Init creates the schema and the first admin user, mirroring the legacy init(). +func (d *Sqlite3Database) Init(username, password string) error { + d.mu.Lock() + defer d.mu.Unlock() + + tx, err := d.db.Begin() + if err != nil { + return err + } + for _, stmt := range sqliteSchema { + if _, err := tx.Exec(stmt); err != nil { + tx.Rollback() + return err + } + } + if _, err := tx.Exec("INSERT INTO user VALUES (?, ?, ?, ?);", username, utils.ComputePasswordHash(password), 1, utils.GenerateSalt()); err != nil { + tx.Rollback() + return err + } + return tx.Commit() +} + +// Close closes the underlying connection. +func (d *Sqlite3Database) Close() error { + return d.db.Close() +} + +// safeOp runs fn inside a serialized transaction. It reproduces the legacy +// SafeDatabaseOperation decorator: lock, begin, periodically purge outdated +// tokens (logging when it does), run the work, commit on success / rollback on +// error. DB errors are logged only in debug mode, matching the legacy +// `if cfg.others.debug: LOGGER.exception(e)` behavior. +func (d *Sqlite3Database) safeOp(ctx context.Context, fn dbOp) ResponseBody { + d.mu.Lock() + defer d.mu.Unlock() + + debug := d.deps.Cfg.Others.Debug + logger := d.deps.Logger + + tx, err := d.db.BeginTx(ctx, nil) + if err != nil { + if debug { + logger.Error("failed to begin transaction", "error", err) + } + return ResponseBody{Success: false, Error: err.Error()} + } + + if now := utils.GetCurrentTimestamp(); now-d.latestClean > d.deps.Cfg.Others.AutoTokenCleanDuration { + d.latestClean = now + logger.Info("Cleaning outdated token...") + if err := tokenOperClean(ctx, tx); err != nil { + tx.Rollback() + if debug { + logger.Error("failed to clean outdated tokens", "error", err) + } + return ResponseBody{Success: false, Error: err.Error()} + } + } + + data, err := fn(ctx, tx) + if err != nil { + tx.Rollback() + if debug { + logger.Error("database operation failed", "error", err) + } + return ResponseBody{Success: false, Error: err.Error()} + } + if err := tx.Commit(); err != nil { + if debug { + logger.Error("failed to commit transaction", "error", err) + } + return ResponseBody{Success: false, Error: err.Error()} + } + return ResponseBody{Success: true, Data: data} +} + +// region: Token-related Internal Operations + +// tokenOperClean deletes outdated tokens. Mirrors tokenOper_clean. +func tokenOperClean(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "DELETE FROM token WHERE [token_expire_on] <= ?;", utils.GetCurrentTimestamp()) + return err +} + +// tokenOperPostponeExpireOn pushes the token's expiry forward. Mirrors +// tokenOper_postpone_expireOn. +func tokenOperPostponeExpireOn(ctx context.Context, tx *sql.Tx, token string) error { + _, err := tx.ExecContext(ctx, "UPDATE token SET [token_expire_on] = ? WHERE [token] = ?;", utils.GetTokenExpireOn(), token) + return err +} + +// tokenOperGetUsername validates token, postpones its expiry, and returns the +// owning username. Mirrors tokenOper_get_username. +func tokenOperGetUsername(ctx context.Context, tx *sql.Tx, token string) (string, error) { + var user string + err := tx.QueryRowContext(ctx, "SELECT [user] FROM token WHERE [token] = ? AND [token_expire_on] > ?;", token, utils.GetCurrentTimestamp()).Scan(&user) + if err != nil { + return "", err + } + if err := tokenOperPostponeExpireOn(ctx, tx, token); err != nil { + return "", err + } + return user, nil +} + +// tokenOperCheckValid validates a token (mirrors tokenOper_check_valid). +func tokenOperCheckValid(ctx context.Context, tx *sql.Tx, token string) error { + _, err := tokenOperGetUsername(ctx, tx, token) + return err +} + +// tokenOperIsAdmin reports whether the user is an admin (mirrors +// tokenOper_is_admin). +func tokenOperIsAdmin(ctx context.Context, tx *sql.Tx, username string) (bool, error) { + var isAdmin int64 + err := tx.QueryRowContext(ctx, "SELECT [is_admin] FROM user WHERE [name] = ?;", username).Scan(&isAdmin) + if err != nil { + return false, err + } + return isAdmin == 1, nil +} + +// endregion + +// region: Operation Functions + +// region: Common Namespace + +func (d *Sqlite3Database) CommonSalt(ctx context.Context, username string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + salt := utils.GenerateSalt() + if _, err := tx.ExecContext(ctx, "UPDATE user SET [salt] = ? WHERE [name] = ?;", salt, username); err != nil { + return nil, err + } + return salt, nil + }) +} + +func (d *Sqlite3Database) CommonLogin(ctx context.Context, username, password, clientUa, clientIp string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + var gottenPassword string + var gottenSalt int64 + err := tx.QueryRowContext(ctx, "SELECT [password], [salt] FROM user WHERE [name] = ?;", username).Scan(&gottenPassword, &gottenSalt) + if err != nil { + return nil, fmt.Errorf("Login authentication failed") + } + if password == utils.ComputePasswordHashWithSalt(gottenPassword, int(gottenSalt)) { + token := utils.GenerateToken(username) + if _, err := tx.ExecContext(ctx, "UPDATE user SET [salt] = ? WHERE [name] = ?;", utils.GenerateSalt(), username); err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, "INSERT INTO token VALUES (?, ?, ?, ?, ?);", username, token, utils.GetTokenExpireOn(), clientUa, clientIp); err != nil { + return nil, err + } + return token, nil + } + return nil, fmt.Errorf("Login authentication failed") + }) +} + +func (d *Sqlite3Database) CommonWebLogin(ctx context.Context, username, password, clientUa, clientIp string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + logger := d.deps.Logger + logger.Debug("WebLogin username", "username", username) + logger.Debug("WebLogin password", "password", password) + passwordHash := utils.ComputePasswordHash(password) + logger.Debug("WebLogin password hash", "hash", passwordHash) + var name string + err := tx.QueryRowContext(ctx, "SELECT [name] FROM user WHERE [name] = ? AND [password] = ?;", username, passwordHash).Scan(&name) + if err != nil { + return nil, fmt.Errorf("Login authentication failed") + } + token := utils.GenerateToken(username) + if _, err := tx.ExecContext(ctx, "INSERT INTO token VALUES (?, ?, ?, ?, ?);", username, token, utils.GetTokenExpireOn(), clientUa, clientIp); err != nil { + return nil, err + } + return token, nil + }) +} + +func (d *Sqlite3Database) CommonLogout(ctx context.Context, token string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, "DELETE FROM token WHERE [token] = ?;", token); err != nil { + return nil, err + } + return true, nil + }) +} + +func (d *Sqlite3Database) CommonTokenValid(ctx context.Context, token string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + return true, nil + }) +} + +// endregion + +// region: Calendar Namespace + +func (d *Sqlite3Database) CalendarGetFull(ctx context.Context, token string, startDateTime, endDateTime int64) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, `SELECT calendar.* FROM calendar INNER JOIN collection + ON collection.uuid = calendar.belong_to + WHERE (collection.user = ? AND calendar.loop_date_time_end >= ? AND calendar.loop_date_time_start - (calendar.event_date_time_end - calendar.event_date_time_start) <= ?);`, + username, startDateTime, endDateTime) + if err != nil { + return nil, err + } + defer rows.Close() + result := [][]any{} + for rows.Next() { + var uuid, belongTo, title, description, lastChange, loopRules string + var eventStart, eventEnd, tzOffset, loopStart, loopEnd int64 + if err := rows.Scan(&uuid, &belongTo, &title, &description, &lastChange, &eventStart, &eventEnd, &tzOffset, &loopRules, &loopStart, &loopEnd); err != nil { + return nil, err + } + result = append(result, []any{uuid, belongTo, title, description, lastChange, eventStart, eventEnd, tzOffset, loopRules, loopStart, loopEnd}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil + }) +} + +func (d *Sqlite3Database) CalendarGetList(ctx context.Context, token string, startDateTime, endDateTime int64) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, `SELECT calendar.uuid FROM calendar INNER JOIN collection + ON collection.uuid = calendar.belong_to + WHERE (collection.user = ? AND calendar.loop_date_time_end >= ? AND calendar.loop_date_time_start - (calendar.event_date_time_end - calendar.event_date_time_start) <= ?);`, + username, startDateTime, endDateTime) + if err != nil { + return nil, err + } + defer rows.Close() + result := []string{} + for rows.Next() { + var uuid string + if err := rows.Scan(&uuid); err != nil { + return nil, err + } + result = append(result, uuid) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil + }) +} + +func (d *Sqlite3Database) CalendarGetDetail(ctx context.Context, token, uuid string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + var dUuid, dBelongTo, dTitle, dDescription, dLastChange, dLoopRules string + var dEventStart, dEventEnd, dTzOffset, dLoopStart, dLoopEnd int64 + err := tx.QueryRowContext(ctx, "SELECT * FROM calendar WHERE [uuid] = ?;", uuid). + Scan(&dUuid, &dBelongTo, &dTitle, &dDescription, &dLastChange, &dEventStart, &dEventEnd, &dTzOffset, &dLoopRules, &dLoopStart, &dLoopEnd) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return []any{dUuid, dBelongTo, dTitle, dDescription, dLastChange, dEventStart, dEventEnd, dTzOffset, dLoopRules, dLoopStart, dLoopEnd}, nil + }) +} + +func (d *Sqlite3Database) CalendarUpdate(ctx context.Context, token, uuid, lastChange string, opts CalendarUpdateOptions) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + + // get prev data + var uUuid, uBelongTo, uTitle, uDescription, uLastChange, uLoopRules string + var uEventStart, uEventEnd, uTzOffset, uLoopStart, uLoopEnd int64 + err := tx.QueryRowContext(ctx, "SELECT * FROM calendar WHERE [uuid] = ? AND [last_change] = ?;", uuid, lastChange). + Scan(&uUuid, &uBelongTo, &uTitle, &uDescription, &uLastChange, &uEventStart, &uEventEnd, &uTzOffset, &uLoopRules, &uLoopStart, &uLoopEnd) + if err != nil { + return nil, err + } + + // construct update data + newLastUpdate := utils.GenerateUUID() + sqlList := []string{"[last_change] = ?"} + args := []any{newLastUpdate} + + // analyse opt arg + reAnalyseLoop := false + effEventStart, effTzOffset, effLoopRules := uEventStart, uTzOffset, uLoopRules + + if opts.BelongTo != nil { + sqlList = append(sqlList, "[belong_to] = ?") + args = append(args, *opts.BelongTo) + } + if opts.Title != nil { + sqlList = append(sqlList, "[title] = ?") + args = append(args, *opts.Title) + } + if opts.Description != nil { + sqlList = append(sqlList, "[description] = ?") + args = append(args, *opts.Description) + } + if opts.EventDateTimeStart != nil { + sqlList = append(sqlList, "[event_date_time_start] = ?") + args = append(args, *opts.EventDateTimeStart) + reAnalyseLoop = true + effEventStart = *opts.EventDateTimeStart + } + if opts.EventDateTimeEnd != nil { + sqlList = append(sqlList, "[event_date_time_end] = ?") + args = append(args, *opts.EventDateTimeEnd) + } + if opts.LoopRules != nil { + sqlList = append(sqlList, "[loop_rules] = ?") + args = append(args, *opts.LoopRules) + reAnalyseLoop = true + effLoopRules = *opts.LoopRules + } + if opts.TimezoneOffset != nil { + sqlList = append(sqlList, "[timezone_offset] = ?") + args = append(args, *opts.TimezoneOffset) + reAnalyseLoop = true + effTzOffset = *opts.TimezoneOffset + } + + if reAnalyseLoop { + // re-compute loop data and upload it into list + sqlList = append(sqlList, "[loop_date_time_start] = ?") + args = append(args, effEventStart) + sqlList = append(sqlList, "[loop_date_time_end] = ?") + loopEnd, err := dt.ResolveLoopStr(effLoopRules, effEventStart, effTzOffset) + if err != nil { + return nil, err + } + args = append(args, loopEnd) + } + + args = append(args, uuid) + query := "UPDATE calendar SET " + strings.Join(sqlList, ", ") + " WHERE [uuid] = ?;" + res, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to update due to no matched rows or too much rows.") + } + return newLastUpdate, nil + }) +} + +func (d *Sqlite3Database) CalendarAdd(ctx context.Context, token, belongTo, title, description string, eventDateTimeStart, eventDateTimeEnd int64, loopRules string, timezoneOffset int64) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + newUuid := utils.GenerateUUID() + newLastUpdate := utils.GenerateUUID() + loopDateTimeStart := eventDateTimeStart + loopDateTimeEnd, err := dt.ResolveLoopStr(loopRules, eventDateTimeStart, timezoneOffset) + if err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, "INSERT INTO calendar VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + newUuid, belongTo, title, description, newLastUpdate, + eventDateTimeStart, eventDateTimeEnd, timezoneOffset, loopRules, + loopDateTimeStart, loopDateTimeEnd); err != nil { + return nil, err + } + return newUuid, nil + }) +} + +func (d *Sqlite3Database) CalendarDelete(ctx context.Context, token, uuid, lastChange string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + res, err := tx.ExecContext(ctx, "DELETE FROM calendar WHERE [uuid] = ? AND [last_change] = ?;", uuid, lastChange) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to delete due to no matched rows or too much rows.") + } + return true, nil + }) +} + +// endregion + +// region: Collection Namespace + +func (d *Sqlite3Database) CollectionGetFullOwn(ctx context.Context, token string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, "SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ?;", username) + if err != nil { + return nil, err + } + defer rows.Close() + result := [][]any{} + for rows.Next() { + var uuid, name, lastChange string + if err := rows.Scan(&uuid, &name, &lastChange); err != nil { + return nil, err + } + result = append(result, []any{uuid, name, lastChange}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil + }) +} + +func (d *Sqlite3Database) CollectionGetListOwn(ctx context.Context, token string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, "SELECT [uuid] FROM collection WHERE [user] = ?;", username) + if err != nil { + return nil, err + } + defer rows.Close() + result := []string{} + for rows.Next() { + var uuid string + if err := rows.Scan(&uuid); err != nil { + return nil, err + } + result = append(result, uuid) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil + }) +} + +func (d *Sqlite3Database) CollectionGetDetailOwn(ctx context.Context, token, uuid string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + var dUuid, dName, dLastChange string + err = tx.QueryRowContext(ctx, "SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ? AND [uuid] = ?;", username, uuid).Scan(&dUuid, &dName, &dLastChange) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return []any{dUuid, dName, dLastChange}, nil + }) +} + +func (d *Sqlite3Database) CollectionAddOwn(ctx context.Context, token, name string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + newUuid := utils.GenerateUUID() + newLastUpdate := utils.GenerateUUID() + if _, err := tx.ExecContext(ctx, "INSERT INTO collection VALUES (?, ?, ?, ?);", newUuid, name, username, newLastUpdate); err != nil { + return nil, err + } + return newUuid, nil + }) +} + +func (d *Sqlite3Database) CollectionUpdateOwn(ctx context.Context, token, uuid, name, lastChange string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + newLastUpdate := utils.GenerateUUID() + res, err := tx.ExecContext(ctx, "UPDATE collection SET [name] = ?, [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;", name, newLastUpdate, uuid, lastChange) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to update due to no matched rows or too much rows.") + } + return newLastUpdate, nil + }) +} + +func (d *Sqlite3Database) CollectionDeleteOwn(ctx context.Context, token, uuid, lastChange string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + res, err := tx.ExecContext(ctx, "DELETE FROM collection WHERE [uuid] = ? AND [last_change] = ?;", uuid, lastChange) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to delete due to no matched rows or too much rows.") + } + return true, nil + }) +} + +func (d *Sqlite3Database) CollectionGetSharing(ctx context.Context, token, uuid string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, "SELECT [target] FROM share WHERE [uuid] = ?;", uuid) + if err != nil { + return nil, err + } + defer rows.Close() + result := []string{} + for rows.Next() { + var target string + if err := rows.Scan(&target); err != nil { + return nil, err + } + result = append(result, target) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil + }) +} + +func (d *Sqlite3Database) CollectionDeleteSharing(ctx context.Context, token, uuid, target, lastChange string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + newLastUpdate := utils.GenerateUUID() + // NOTE: the legacy SQL had a stray comma ("SET [last_change] = ?, WHERE ...") + // which is a syntax error; it is corrected here. + res, err := tx.ExecContext(ctx, "UPDATE collection SET [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;", newLastUpdate, uuid, lastChange) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to delete due to no matched rows or too much rows.") + } + res, err = tx.ExecContext(ctx, "DELETE FROM share WHERE [uuid] = ? AND [target] = ?;", uuid, target) + if err != nil { + return nil, err + } + n, err = res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to delete due to no matched rows or too much rows.") + } + return newLastUpdate, nil + }) +} + +func (d *Sqlite3Database) CollectionAddSharing(ctx context.Context, token, uuid, target, lastChange string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + newLastUpdate := utils.GenerateUUID() + res, err := tx.ExecContext(ctx, "UPDATE collection SET [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;", newLastUpdate, uuid, lastChange) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to delete due to no matched rows or too much rows.") + } + var dupUuid, dupTarget string + err = tx.QueryRowContext(ctx, "SELECT * FROM share WHERE [uuid] = ? AND [target] = ?;", uuid, target).Scan(&dupUuid, &dupTarget) + if err == nil { + return nil, fmt.Errorf("Fail to insert duplicated item.") + } else if err != sql.ErrNoRows { + return nil, err + } + if _, err := tx.ExecContext(ctx, "INSERT INTO share VALUES (?, ?);", uuid, target); err != nil { + return nil, err + } + return newLastUpdate, nil + }) +} + +func (d *Sqlite3Database) CollectionGetShared(ctx context.Context, token string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, `SELECT collection.uuid, collection.name, collection.user + FROM share INNER JOIN collection + ON share.uuid = collection.uuid + WHERE share.target = ?;`, username) + if err != nil { + return nil, err + } + defer rows.Close() + result := [][]any{} + for rows.Next() { + var uuid, name, user string + if err := rows.Scan(&uuid, &name, &user); err != nil { + return nil, err + } + result = append(result, []any{uuid, name, user}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil + }) +} + +// endregion + +// region: Todo Namespace + +func (d *Sqlite3Database) TodoGetFull(ctx context.Context, token string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, "SELECT * FROM todo WHERE [belong_to] = ?;", username) + if err != nil { + return nil, err + } + defer rows.Close() + result := [][]any{} + for rows.Next() { + var uuid, belongTo, data, lastChange string + if err := rows.Scan(&uuid, &belongTo, &data, &lastChange); err != nil { + return nil, err + } + result = append(result, []any{uuid, belongTo, data, lastChange}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil + }) +} + +func (d *Sqlite3Database) TodoGetList(ctx context.Context, token string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, "SELECT [uuid] FROM todo WHERE [belong_to] = ?;", username) + if err != nil { + return nil, err + } + defer rows.Close() + result := []string{} + for rows.Next() { + var uuid string + if err := rows.Scan(&uuid); err != nil { + return nil, err + } + result = append(result, uuid) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil + }) +} + +func (d *Sqlite3Database) TodoGetDetail(ctx context.Context, token, uuid string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + var dUuid, dBelongTo, dData, dLastChange string + err = tx.QueryRowContext(ctx, "SELECT * FROM todo WHERE [belong_to] = ? AND [uuid] = ?;", username, uuid).Scan(&dUuid, &dBelongTo, &dData, &dLastChange) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return []any{dUuid, dBelongTo, dData, dLastChange}, nil + }) +} + +func (d *Sqlite3Database) TodoAdd(ctx context.Context, token string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + newUuid := utils.GenerateUUID() + newLastUpdate := utils.GenerateUUID() + returnedData := []any{newUuid, username, "", newLastUpdate} + if _, err := tx.ExecContext(ctx, "INSERT INTO todo VALUES (?, ?, ?, ?);", newUuid, username, "", newLastUpdate); err != nil { + return nil, err + } + return returnedData, nil + }) +} + +func (d *Sqlite3Database) TodoUpdate(ctx context.Context, token, uuid, data, lastChange string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + // check valid token + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + + // update + newLastChange := utils.GenerateUUID() + res, err := tx.ExecContext(ctx, "UPDATE todo SET [data] = ?, [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;", data, newLastChange, uuid, lastChange) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to update due to no matched rows or too much rows.") + } + return newLastChange, nil + }) +} + +func (d *Sqlite3Database) TodoDelete(ctx context.Context, token, uuid, lastChange string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + // check valid token + if err := tokenOperCheckValid(ctx, tx, token); err != nil { + return nil, err + } + + // delete + res, err := tx.ExecContext(ctx, "DELETE FROM todo WHERE [uuid] = ? AND [last_change] = ?;", uuid, lastChange) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to delete due to no matched rows or too much rows.") + } + return true, nil + }) +} + +// endregion + +// region: Admin Namespace + +func (d *Sqlite3Database) AdminGet(ctx context.Context, token string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + isAdmin, err := tokenOperIsAdmin(ctx, tx, username) + if err != nil { + return nil, err + } + if !isAdmin { + return nil, fmt.Errorf("Permission denied.") + } + rows, err := tx.QueryContext(ctx, "SELECT [name], [is_admin] FROM user;") + if err != nil { + return nil, err + } + defer rows.Close() + result := [][]any{} + for rows.Next() { + var name string + var isAdminVal int64 + if err := rows.Scan(&name, &isAdminVal); err != nil { + return nil, err + } + result = append(result, []any{name, isAdminVal == 1}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil + }) +} + +func (d *Sqlite3Database) AdminAdd(ctx context.Context, token, username string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + caller, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + isAdmin, err := tokenOperIsAdmin(ctx, tx, caller) + if err != nil { + return nil, err + } + if !isAdmin { + return nil, fmt.Errorf("Permission denied.") + } + newPassword := utils.ComputePasswordHash(utils.GenerateUUID()) + if _, err := tx.ExecContext(ctx, "INSERT INTO user VALUES (?, ?, ?, ?);", username, newPassword, 0, utils.GenerateSalt()); err != nil { + return nil, err + } + return []any{username, false}, nil + }) +} + +func (d *Sqlite3Database) AdminUpdate(ctx context.Context, token, username string, opts AdminUpdateOptions) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + caller, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + isAdmin, err := tokenOperIsAdmin(ctx, tx, caller) + if err != nil { + return nil, err + } + if !isAdmin { + return nil, fmt.Errorf("Permission denied.") + } + + // construct data + sqlList := []string{} + args := []any{} + + // analyse opt arg + if opts.Password != nil { + sqlList = append(sqlList, "[password] = ?") + args = append(args, utils.ComputePasswordHash(*opts.Password)) + } + if opts.IsAdmin != nil { + sqlList = append(sqlList, "[is_admin] = ?") + if *opts.IsAdmin { + args = append(args, 1) + } else { + args = append(args, 0) + } + } + if len(sqlList) == 0 { + return nil, fmt.Errorf("No fields to update.") + } + + // execute + args = append(args, username) + d.deps.Logger.Debug("admin update", "args", args) + query := "UPDATE user SET " + strings.Join(sqlList, ", ") + " WHERE [name] = ?;" + res, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to update due to no matched rows or too much rows.") + } + return true, nil + }) +} + +func (d *Sqlite3Database) AdminDelete(ctx context.Context, token, username string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + caller, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + isAdmin, err := tokenOperIsAdmin(ctx, tx, caller) + if err != nil { + return nil, err + } + if !isAdmin { + return nil, fmt.Errorf("Permission denied.") + } + + // delete + res, err := tx.ExecContext(ctx, "DELETE FROM user WHERE [name] = ?;", username) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to delete due to no matched rows or too much rows.") + } + return true, nil + }) +} + +// region: Profile Namespace + +func (d *Sqlite3Database) ProfileIsAdmin(ctx context.Context, token string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + return tokenOperIsAdmin(ctx, tx, username) + }) +} + +func (d *Sqlite3Database) ProfileChangePassword(ctx context.Context, token, password string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, "UPDATE user SET [password] = ? WHERE [name] = ?;", utils.ComputePasswordHash(password), username); err != nil { + return nil, err + } + return true, nil + }) +} + +func (d *Sqlite3Database) ProfileGetToken(ctx context.Context, token string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, "SELECT * FROM token WHERE [user] = ?;", username) + if err != nil { + return nil, err + } + defer rows.Close() + result := [][]any{} + for rows.Next() { + var user, tokenVal, ua, ip string + var expireOn int64 + if err := rows.Scan(&user, &tokenVal, &expireOn, &ua, &ip); err != nil { + return nil, err + } + result = append(result, []any{user, tokenVal, expireOn, ua, ip}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil + }) +} + +func (d *Sqlite3Database) ProfileDeleteToken(ctx context.Context, token, deleteToken string) ResponseBody { + return d.safeOp(ctx, func(ctx context.Context, tx *sql.Tx) (any, error) { + username, err := tokenOperGetUsername(ctx, tx, token) + if err != nil { + return nil, err + } + + // delete + res, err := tx.ExecContext(ctx, "DELETE FROM token WHERE [user] = ? AND [token] = ?;", username, deleteToken) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n != 1 { + return nil, fmt.Errorf("Fail to delete due to no matched rows or too much rows.") + } + return true, nil + }) +} + +// endregion diff --git a/backend/go.mod b/backend/go.mod index e518dcd..9eaf9ce 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,6 +24,7 @@ require ( github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-sqlite3 v1.14.48 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect diff --git a/backend/go.sum b/backend/go.sum index 71ba1eb..4a2cb5e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -62,6 +62,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=