summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBenjamin Chausse <benjamin@chausse.xyz>2025-02-22 09:59:10 -0500
committerBenjamin Chausse <benjamin@chausse.xyz>2025-02-22 09:59:10 -0500
commitf36f77472a82d6ebfac153aed6d17f154ae239a6 (patch)
treed749ecc2ebf86a39b15ac3026d3e100d0276442b
parent2cb9e5fe823391c09a99424138192d0fbec727af (diff)
Good foundations
-rw-r--r--Makefile4
-rw-r--r--internal/app/action.go110
-rw-r--r--internal/app/flags.go33
-rw-r--r--internal/autogenerate/manualgen.go32
-rw-r--r--internal/db/data_validation.go67
-rw-r--r--internal/db/schema_initialisation.go130
-rw-r--r--internal/db/setup.go234
-rw-r--r--internal/db/transaction_definitions.go108
-rw-r--r--internal/intercept/tagging.go (renamed from internal/tagging/tagging.go)4
-rw-r--r--internal/logging/logging.go1
-rw-r--r--internal/manualgen/manualgen.go21
-rw-r--r--internal/pb/mutate_admin.go20
-rw-r--r--internal/pb/mutate_user.go39
-rw-r--r--internal/pb/query_admin.go1
-rw-r--r--internal/pb/query_user.go19
-rw-r--r--internal/pb/server.go47
-rw-r--r--internal/server/server.go16
-rw-r--r--internal/server/setup.go36
-rw-r--r--internal/server/task.go39
-rw-r--r--internal/server/user.go39
-rw-r--r--internal/storage/db.go18
-rw-r--r--internal/storage/schema.go38
-rw-r--r--internal/storage/setup.go201
-rw-r--r--internal/util/context.go1
-rw-r--r--pkg/model/.gitignore (renamed from internal/server/model/.gitignore)0
-rw-r--r--resources/Dockerfile23
-rwxr-xr-xresources/local_dev.sh3
-rw-r--r--resources/schema.proto85
28 files changed, 895 insertions, 474 deletions
diff --git a/Makefile b/Makefile
index 7ef18f3..903549d 100644
--- a/Makefile
+++ b/Makefile
@@ -10,9 +10,9 @@ codegen:
protoc \
--proto_path=resources \
--proto_path=external \
- --go_out=internal/server/model \
+ --go_out=pkg/model \
--go_opt=paths=source_relative \
- --go-grpc_out=internal/server/model \
+ --go-grpc_out=pkg/model \
--go-grpc_opt=paths=source_relative \
resources/schema.proto
diff --git a/internal/app/action.go b/internal/app/action.go
index b579253..78bb251 100644
--- a/internal/app/action.go
+++ b/internal/app/action.go
@@ -4,11 +4,18 @@ import (
"context"
"fmt"
"log/slog"
+ "net"
+ "os"
+ "os/signal"
+ "sync"
+ "syscall"
+ "time"
+ "github.com/ChausseBenjamin/rafta/internal/db"
"github.com/ChausseBenjamin/rafta/internal/logging"
- "github.com/ChausseBenjamin/rafta/internal/server"
- "github.com/ChausseBenjamin/rafta/internal/storage"
+ "github.com/ChausseBenjamin/rafta/internal/pb"
"github.com/urfave/cli/v3"
+ "google.golang.org/grpc"
)
func action(ctx context.Context, cmd *cli.Command) error {
@@ -18,30 +25,99 @@ func action(ctx context.Context, cmd *cli.Command) error {
cmd.String(FlagLogOutput),
)
if err != nil {
- slog.Warn("Error(s) occured during logger initialization", logging.ErrKey, err)
+ slog.WarnContext(ctx, "Error(s) occurred during logger initialization", logging.ErrKey, err)
}
+ slog.InfoContext(ctx, "Starting rafta server")
- slog.Info("Starting rafta server")
+ errAppChan := make(chan error)
+ readyChan := make(chan bool)
+ shutdownDone := make(chan struct{}) // Signals when graceful shutdown is complete
- // TODO: Setup the db
- store, err := storage.Setup(cmd.String(FlagDBPath))
- if err != nil {
- slog.Error("Unable to setup database", logging.ErrKey, err)
+ var once sync.Once
+ gracefulShutdown := func() {}
+ brutalShutdown := func() {}
+
+ application := func() {
+ server, store, err := initApp(ctx, cmd)
+ if err != nil {
+ errAppChan <- err
+ return
+ }
+
+ gracefulShutdown = func() {
+ once.Do(func() { // Ensure brutal shutdown isn't triggered later
+ server.GracefulStop()
+ store.Close()
+ slog.InfoContext(ctx, "Application shutdown")
+ close(shutdownDone) // Signal that graceful shutdown is complete
+ })
+ }
+
+ brutalShutdown = func() {
+ once.Do(func() { // Ensure graceful shutdown isn't re-executed
+ slog.WarnContext(ctx, "Graceful shutdown delay exceeded, shutting down NOW!")
+ server.Stop()
+ store.Close()
+ })
+ }
+
+ port := fmt.Sprintf(":%d", cmd.Int(FlagListenPort))
+ listener, err := net.Listen("tcp", port)
+ if err != nil {
+ errAppChan <- err
+ return
+ }
+ slog.InfoContext(ctx, "Server listening", "port", cmd.Int(FlagListenPort))
+ readyChan <- true
+
+ if err := server.Serve(listener); err != nil {
+ errAppChan <- err
+ }
}
+ go application()
- srv, lis, err := server.Setup(cmd.Int(FlagListenPort), store)
- if err != nil {
- slog.Error("Unable to setup server", logging.ErrKey, err)
+ stopChan := waitForTermChan()
+ running := true
+ for running {
+ select {
+ case errApp := <-errAppChan:
+ if errApp != nil {
+ slog.ErrorContext(ctx, "Application error", logging.ErrKey, errApp)
+ }
+ return errApp
+ case <-stopChan:
+ slog.InfoContext(ctx, "Shutdown requested")
+ go gracefulShutdown()
- return err
+ select {
+ case <-shutdownDone: // If graceful shutdown completes in time, exit normally
+ case <-time.After(cmd.Duration(FlagGraceTimeout)): // Timeout exceeded
+ brutalShutdown()
+ }
+ running = false
+ }
}
+ return nil
+}
- slog.Info(fmt.Sprintf("Listening on port %d", cmd.Int(FlagListenPort)))
- if err := srv.Serve(lis); err != nil {
- slog.Error("Server runtime error", logging.ErrKey, err)
+func waitForTermChan() chan os.Signal {
+ stopChan := make(chan os.Signal, 1)
+ signal.Notify(stopChan, syscall.SIGINT, syscall.SIGTERM)
+ return stopChan
+}
- return err
+func initApp(ctx context.Context, cmd *cli.Command) (*grpc.Server, *db.Store, error) {
+ store, err := db.Setup(ctx, cmd.String(FlagDBPath))
+ if err != nil {
+ slog.ErrorContext(ctx, "Unable to setup database", logging.ErrKey, err)
+ return nil, nil, err
}
- return nil
+ server, err := pb.Setup(ctx, store)
+ if err != nil {
+ slog.ErrorContext(ctx, "Unable to setup gRPC server", logging.ErrKey, err)
+ return nil, nil, err
+ }
+
+ return server, store, nil
}
diff --git a/internal/app/flags.go b/internal/app/flags.go
index e96b8ef..d83dae8 100644
--- a/internal/app/flags.go
+++ b/internal/app/flags.go
@@ -6,18 +6,20 @@ import (
"log/slog"
"os"
"strings"
+ "time"
"github.com/ChausseBenjamin/rafta/internal/logging"
- "github.com/ChausseBenjamin/rafta/internal/server"
+ "github.com/ChausseBenjamin/rafta/internal/pb"
"github.com/urfave/cli/v3"
)
const (
- FlagListenPort = "port"
- FlagLogLevel = "log-level"
- FlagLogFormat = "log-format"
- FlagLogOutput = "log-output"
- FlagDBPath = "database"
+ FlagListenPort = "port"
+ FlagLogLevel = "log-level"
+ FlagLogFormat = "log-format"
+ FlagLogOutput = "log-output"
+ FlagDBPath = "database"
+ FlagGraceTimeout = "grace-timeout"
)
func flags() []cli.Flag {
@@ -51,9 +53,15 @@ func flags() []cli.Flag {
&cli.IntFlag{
Name: FlagListenPort,
Aliases: []string{"p"},
- Value: 1234,
+ Value: 1157, // list in leetspeek :P
Sources: cli.EnvVars("LISTEN_PORT"),
Action: validateListenPort,
+ },
+ &cli.DurationFlag{
+ Name: FlagGraceTimeout,
+ Aliases: []string{"t"},
+ Value: 5 * time.Second,
+ Sources: cli.EnvVars("GRACEFUL_TIMEOUT"),
}, // }}}
// Database {{{
&cli.StringFlag{
@@ -62,7 +70,6 @@ func flags() []cli.Flag {
Value: "store.db",
Usage: "database file",
Sources: cli.EnvVars("DATABASE_PATH"),
- Action: validateDBPath,
}, // }}}
}
}
@@ -113,15 +120,7 @@ func validateListenPort(ctx context.Context, cmd *cli.Command, p int64) error {
ctx,
fmt.Sprintf("Out-of-bound port provided: %d", p),
)
- return server.ErrOutOfBoundsPort
+ return pb.ErrOutOfBoundsPort
}
return nil
}
-
-func validateDBPath(ctx context.Context, cmd *cli.Command, s string) error {
- // TODO: Ensure the db file is writable.
- // TODO: Ensure the db file is a valid sqlite3 db.
- // TODO: Call db.Reset() if either of the above fail.
- // TODO: Log the error/crash if the db file is not writable.
- return nil
-}
diff --git a/internal/autogenerate/manualgen.go b/internal/autogenerate/manualgen.go
new file mode 100644
index 0000000..a3480c4
--- /dev/null
+++ b/internal/autogenerate/manualgen.go
@@ -0,0 +1,32 @@
+/*
+ * This package isn't the actual rafta server.
+ * To avoid importing packages which aren't needed at runtime,
+ * some auto-generation functionnalities is offloaded to here so
+ * it can be done with access to the rest of the code-base but
+ * without bloating the final binary. For example,
+ * generating bash+zsh auto-completion scripts isn't needed in
+ * the final binary if those script are generated before hand.
+ * Same gose for manpages. This file is meant to be run automatically
+ * to easily package new releases.
+ */
+package main
+
+import (
+ "log/slog"
+ "os"
+
+ "github.com/ChausseBenjamin/rafta/internal/app"
+ "github.com/ChausseBenjamin/rafta/internal/logging"
+ docs "github.com/urfave/cli-docs/v3"
+)
+
+func main() {
+ a := app.Command()
+
+ man, err := docs.ToManWithSection(a, 1)
+ if err != nil {
+ slog.Error("failed to generate man page", logging.ErrKey, err)
+ os.Exit(1)
+ }
+ os.Stdout.Write([]byte(man))
+}
diff --git a/internal/db/data_validation.go b/internal/db/data_validation.go
new file mode 100644
index 0000000..1883dc0
--- /dev/null
+++ b/internal/db/data_validation.go
@@ -0,0 +1,67 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "log/slog"
+)
+
+const (
+ PublicUserSignupKey = "ACCEPT_PUBLIC_USERS"
+ EnforceHttpsKey = "ENFORCE_HTTPS"
+ MaxUsersKey = "MAX_USERS"
+)
+
+var settingValidations = [...]struct {
+ key string // key to look for in the settings
+ defaultVal string // Default value to init if not set
+}{
+ { // Only admins can create users when false
+ PublicUserSignupKey,
+ "FALSE",
+ },
+ { // If something like traefik manages https, this can be set to
+ // false. But there MUST be https in your stack otherwise
+ // credentials are sent in the clear
+ EnforceHttpsKey,
+ "TRUE",
+ },
+ { // Safeguard to avoid account creation spamming.
+ // An admin can still create users over the limit
+ MaxUsersKey,
+ "25",
+ },
+}
+
+func ValidateSettings(ctx context.Context, db *sql.DB) error {
+ valTx, err := db.PrepareContext(ctx, "SELECT value FROM Settings WHERE key=?")
+ if err != nil {
+ return err
+ }
+ defer valTx.Close()
+
+ newTx, err := db.PrepareContext(ctx, "INSERT INTO Settings (key, value) VALUES (?, ?)")
+ if err != nil {
+ return err
+ }
+ defer newTx.Close()
+
+ for _, s := range settingValidations {
+ var val string
+ err := valTx.QueryRowContext(ctx, s.key).Scan(&val)
+ if err != nil {
+ return err
+ }
+ if val == "" {
+ slog.WarnContext(ctx, "Missing configuration, setting the default",
+ "setting", s.key,
+ "value", s.defaultVal,
+ )
+ _, err := newTx.ExecContext(ctx, s.key, s.defaultVal)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/db/schema_initialisation.go b/internal/db/schema_initialisation.go
new file mode 100644
index 0000000..3963786
--- /dev/null
+++ b/internal/db/schema_initialisation.go
@@ -0,0 +1,130 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "log/slog"
+)
+
+// schemaDefinitions is the single source of truth for both creating and validating the DB schema.
+var schemaDefinitions = [...]struct {
+ Name string
+ Cmd string
+}{
+ {
+ "Users",
+ `CREATE TABLE Users (
+ userID TEXT PRIMARY KEY CHECK (length(userID) = 36),
+ name TEXT NOT NULL,
+ email TEXT NOT NULL UNIQUE,
+ createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );`,
+ },
+ {
+ "UserSecrets",
+ `CREATE TABLE UserSecrets (
+ userID TEXT PRIMARY KEY,
+ saltAndHash TEXT NOT NULL,
+ FOREIGN KEY (userID) REFERENCES Users(userID) ON DELETE CASCADE
+ );`,
+ },
+ {
+ "Tasks",
+ `CREATE TABLE Tasks (
+ taskID TEXT PRIMARY KEY CHECK (length(taskID) = 36),
+ title TEXT NOT NULL,
+ priority INTEGER NOT NULL DEFAULT 0,
+ description TEXT,
+ due TIMESTAMP,
+ do TIMESTAMP,
+ cron TEXT,
+ cronIsEnabled BOOLEAN NOT NULL DEFAULT FALSE,
+ owner TEXT NOT NULL,
+ FOREIGN KEY (owner) REFERENCES Users(userID) ON DELETE CASCADE
+ );`,
+ },
+ {
+ "Tags",
+ `CREATE TABLE Tags (
+ tagID INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE
+ );`,
+ },
+ {
+ "TaskTags",
+ `CREATE TABLE TaskTags (
+ taskID TEXT NOT NULL,
+ tagID INTEGER NOT NULL,
+ PRIMARY KEY (taskID, tagID),
+ FOREIGN KEY (taskID) REFERENCES Tasks(taskID) ON DELETE CASCADE,
+ FOREIGN KEY (tagID) REFERENCES Tags(tagID) ON DELETE CASCADE
+ );`,
+ },
+ {
+ "Settings",
+ `CREATE TABLE Settings (
+ key TEXT PRIMARY KEY,
+ value TEXT
+ );`,
+ },
+ {
+ "Roles",
+ `CREATE TABLE Roles (
+ role TEXT PRIMARY KEY CHECK (role GLOB '[A-Z_]*')
+ );`,
+ },
+ {
+ "UserRoles",
+ `CREATE TABLE UserRoles (
+ userID TEXT NOT NULL,
+ role TEXT NOT NULL,
+ PRIMARY KEY (userID, role),
+ FOREIGN KEY (userID) REFERENCES Users(userID) ON DELETE CASCADE,
+ FOREIGN KEY (role) REFERENCES Roles(role) ON DELETE CASCADE
+ );`,
+ },
+}
+
+// genDB creates a new database at path using the expected schema definitions.
+func genDB(ctx context.Context, path string) (*sql.DB, error) {
+ db, err := sql.Open("sqlite3", path)
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to create DB", "error", err)
+ return nil, err
+ }
+
+ // Set the required PRAGMAs.
+ if _, err := db.Exec("PRAGMA foreign_keys = on; PRAGMA journal_mode = wal;"); err != nil {
+ slog.ErrorContext(ctx, "failed to set pragmas", "error", err)
+ db.Close()
+ return nil, err
+ }
+
+ // Create tables inside a transaction.
+ tx, err := db.Begin()
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to begin transaction for schema initialization", "error", err)
+ db.Close()
+ return nil, err
+ }
+ for _, table := range schemaDefinitions {
+ if _, err := tx.Exec(table.Cmd); err != nil {
+ slog.ErrorContext(ctx, "failed to initialize schema", "table", table.Name, "error", err)
+ if errRollback := tx.Rollback(); errRollback != nil {
+ slog.ErrorContext(ctx, "failed to rollback schema initialization", "error", errRollback)
+ }
+
+ db.Close()
+ return nil, err
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ slog.ErrorContext(ctx, "failed to commit schema initialization", "error", err)
+ db.Close()
+ return nil, err
+ }
+
+ slog.InfoContext(ctx, "created new blank DB wit h valid schema", "path", path)
+ return db, nil
+}
diff --git a/internal/db/setup.go b/internal/db/setup.go
new file mode 100644
index 0000000..038334a
--- /dev/null
+++ b/internal/db/setup.go
@@ -0,0 +1,234 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+var (
+ ErrForeignKeysDisabled = errors.New("foreign keys are disabled")
+ ErrIntegrityCheckFailed = errors.New("integrity check failed")
+ ErrJournalModeInvalid = errors.New("journal mode is not WAL")
+ ErrSchemaMismatch = errors.New("database schema does not match expected definition")
+ ErrTableMissing = errors.New("table is missing")
+ ErrTableStructure = errors.New("table structure does not match expected schema")
+)
+
+type Store struct {
+ DB *sql.DB
+ Common []*sql.Stmt
+}
+
+func new(db *sql.DB) (*Store, error) {
+ lst := make([]*sql.Stmt, len(commonTransactions))
+ for _, common := range commonTransactions {
+ stmt, err := db.Prepare(common.Cmd)
+ if err != nil {
+ return nil, err
+ }
+ lst[common.Name] = stmt
+ }
+ return &Store{
+ DB: db,
+ Common: lst,
+ }, nil
+}
+
+func (s *Store) Close() error {
+ errs := make([]error, len(s.Common)+1)
+ for i, s := range s.Common {
+ if s != nil {
+ errs[i] = s.Close()
+ }
+ }
+ errs[len(s.Common)] = s.DB.Close()
+ return errors.Join(errs...)
+}
+
+// opts returns connection options that enforce our desired pragmas.
+func opts() string {
+ return "?_foreign_keys=on&_journal_mode=WAL"
+}
+
+// Setup opens the SQLite DB at path, verifies its integrity and schema,
+// and returns the valid DB handle. If any check fails, it backs up the old file
+// and reinitializes the DB using the schema definitions.
+func Setup(ctx context.Context, path string) (*Store, error) {
+ slog.DebugContext(ctx, "Setting up database connection")
+
+ // If file does not exist, generate a new DB.
+ if _, err := os.Stat(path); err != nil {
+ db, err := genDB(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+ return new(db)
+ }
+
+ db, err := sql.Open("sqlite3", path+opts())
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to open DB", "error", err)
+ backupFile(ctx, path)
+ db, err := genDB(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+ return new(db)
+ }
+
+ // Run integrity check.
+ var integrity string
+ if err = db.QueryRow("PRAGMA integrity_check;").Scan(&integrity); err != nil || integrity != "ok" {
+ if err != nil {
+ slog.ErrorContext(ctx, "integrity check query failed", "error", err)
+ } else {
+ slog.ErrorContext(ctx, "integrity check failed", "integrity", integrity)
+ }
+ db.Close()
+ backupFile(ctx, path)
+ db, err := genDB(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+ return new(db)
+ }
+
+ // Validate the PRAGMA settings and each table's schema.
+ if err = validateSchema(ctx, db); err != nil {
+ slog.ErrorContext(ctx, "schema validation failed", "error", err)
+ db.Close()
+ backupFile(ctx, path)
+ db, err := genDB(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+ return new(db)
+ }
+
+ return new(db)
+}
+
+// validateSchema checks that the PRAGMAs and every table definition match the expected schema.
+func validateSchema(ctx context.Context, db *sql.DB) error {
+ if err := validatePragmas(db); err != nil {
+ return err
+ }
+ for _, table := range schemaDefinitions {
+ if err := validateTable(ctx, db, table.Name, table.Cmd); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// validatePragmas ensures that the required PRAGMAs are set.
+func validatePragmas(db *sql.DB) error {
+ var fk int
+ if err := db.QueryRow("PRAGMA foreign_keys;").Scan(&fk); err != nil {
+ return err
+ }
+ if fk != 1 {
+ return ErrForeignKeysDisabled
+ }
+
+ var jm string
+ if err := db.QueryRow("PRAGMA journal_mode;").Scan(&jm); err != nil {
+ return err
+ }
+ if strings.ToLower(jm) != "wal" {
+ return ErrJournalModeInvalid
+ }
+ return nil
+}
+
+// validateTable fetches the stored SQL for the table and compares it
+// (after normalization) with the expected definition.
+func validateTable(ctx context.Context, db *sql.DB, tableName, expectedSQL string) error {
+ actualSQL, err := fetchTableSQL(db, tableName)
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to fetch table definition", "table", tableName, "error", err)
+ return ErrSchemaMismatch
+ }
+ if actualSQL == "" {
+ slog.ErrorContext(ctx, "table is missing", "table", tableName)
+ return ErrTableMissing
+ }
+
+ normalizedExpected := normalizeSQL(expectedSQL)
+ normalizedActual := normalizeSQL(actualSQL)
+ if normalizedExpected != normalizedActual {
+ slog.ErrorContext(ctx, "table structure does not match expected schema",
+ "table", tableName,
+ "expected", normalizedExpected,
+ "actual", normalizedActual,
+ )
+ return ErrTableStructure
+ }
+ return nil
+}
+
+// normalizeSQL removes SQL comments, converts to lowercase,
+// collapses whitespace, and removes a trailing semicolon.
+func normalizeSQL(sqlStr string) string {
+ sqlStr = removeSQLComments(sqlStr)
+ sqlStr = strings.ToLower(sqlStr)
+ sqlStr = strings.ReplaceAll(sqlStr, "\n", " ")
+ sqlStr = strings.Join(strings.Fields(sqlStr), " ")
+ sqlStr = strings.TrimSuffix(sqlStr, ";")
+ return sqlStr
+}
+
+// removeSQLComments strips out any '--' style comments.
+func removeSQLComments(sqlStr string) string {
+ lines := strings.Split(sqlStr, "\n")
+ for i, line := range lines {
+ if idx := strings.Index(line, "--"); idx != -1 {
+ lines[i] = line[:idx]
+ }
+ }
+ return strings.Join(lines, " ")
+}
+
+// fetchTableSQL retrieves the SQL definition of a table from sqlite_master.
+func fetchTableSQL(db *sql.DB, table string) (string, error) {
+ var sqlDef sql.NullString
+ err := db.QueryRow(
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
+ table,
+ ).Scan(&sqlDef)
+ if err != nil {
+ return "", err
+ }
+ if !sqlDef.Valid {
+ return "", fmt.Errorf("no SQL definition found for table %s", table)
+ }
+ return sqlDef.String, nil
+}
+
+// backupFile renames the existing file by appending a ".bak" (or timestamped) suffix.
+func backupFile(ctx context.Context, path string) {
+ backupPath := path + ".bak"
+ if _, err := os.Stat(backupPath); err == nil {
+ backupPath = fmt.Sprintf("%s-%s.bak", path, time.Now().Format(time.RFC3339))
+ }
+ if err := os.Rename(path, backupPath); err != nil {
+ slog.ErrorContext(ctx, "failed to backup file",
+ "error", err,
+ "original", path,
+ "backup", backupPath,
+ )
+ } else {
+ slog.InfoContext(ctx, "backed up corrupt DB",
+ "original", path,
+ "backup", backupPath,
+ )
+ }
+}
diff --git a/internal/db/transaction_definitions.go b/internal/db/transaction_definitions.go
new file mode 100644
index 0000000..cdfa433
--- /dev/null
+++ b/internal/db/transaction_definitions.go
@@ -0,0 +1,108 @@
+package db
+
+type transactionName int
+
+const (
+ CreateUser transactionName = iota
+ CreateUserSecret
+ CreateTag
+ CreateRole
+ RemoveUser
+ RemoveRole
+ RemoveUnusedTags
+ AssignRoleToUser
+ AssignTagToTask
+ RemoveRoleFromUser
+ RemoveTagFromTask
+ UpdateSetting
+ GetSingleUser
+ GetAllUsers
+ GetSingleTask
+ GetAllTasks
+ GetSingleUserWithSecretAndRoles
+ GetAllTagsRelatedToTask
+)
+
+var commonTransactions = [...]struct {
+ Name transactionName
+ Cmd string
+}{
+ { // Create a user (including salted secret)
+ Name: CreateUser,
+ Cmd: "INSERT INTO Users (userID, name, email) VALUES (?, ?, ?)",
+ },
+ { // Create user secrets
+ Name: CreateUserSecret,
+ Cmd: "INSERT INTO UserSecrets (userID, saltAndHash) VALUES (?, ?)",
+ },
+ { // Create a tag
+ Name: CreateTag,
+ Cmd: "INSERT INTO Tags (name) VALUES (?)",
+ },
+ { // Create a role
+ Name: CreateRole,
+ Cmd: "INSERT INTO Roles (role) VALUES (?)",
+ },
+ { // Remove a user
+ Name: RemoveUser,
+ Cmd: "DELETE FROM Users WHERE userID = ?",
+ },
+ { // Remove a role
+ Name: RemoveRole,
+ Cmd: "DELETE FROM Roles WHERE role = ?",
+ },
+ { // Remove unused tags (assigned to no tasks)
+ Name: RemoveUnusedTags,
+ Cmd: "DELETE FROM Tags WHERE tagID NOT IN (SELECT tagID FROM TaskTags)",
+ },
+ { // Assign a new role to a user
+ Name: AssignRoleToUser,
+ Cmd: "INSERT INTO UserRoles (userID, role) VALUES (?, ?)",
+ },
+ { // Assign a new tag to a task
+ Name: AssignTagToTask,
+ Cmd: "INSERT INTO TaskTags (taskID, tagID) VALUES (?, ?)",
+ },
+ { // Remove a role from a user
+ Name: RemoveRoleFromUser,
+ Cmd: "DELETE FROM UserRoles WHERE userID = ? AND role = ?",
+ },
+ { // Remove a tag from a task
+ Name: RemoveTagFromTask,
+ Cmd: "DELETE FROM TaskTags WHERE taskID = ? AND tagID = ?",
+ },
+ { // Update a setting KeyPair
+ Name: UpdateSetting,
+ Cmd: "UPDATE Settings SET value = ? WHERE key = ?",
+ },
+ { // Get a single user
+ Name: GetSingleUser,
+ Cmd: "SELECT * FROM Users WHERE userID = ?",
+ },
+ { // Get all users
+ Name: GetAllUsers,
+ Cmd: "SELECT * FROM Users",
+ },
+ { // Get a single task
+ Name: GetSingleTask,
+ Cmd: "SELECT * FROM Tasks WHERE taskID = ?",
+ },
+ { // Get all tasks
+ Name: GetAllTasks,
+ Cmd: "SELECT * FROM Tasks",
+ },
+ { // Get a single user with secret info and roles
+ Name: GetSingleUserWithSecretAndRoles,
+ Cmd: `SELECT u.*, us.saltAndHash, ur.role
+ FROM Users u
+ JOIN UserSecrets us ON u.userID = us.userID
+ LEFT JOIN UserRoles ur ON u.userID = ur.userID
+ WHERE u.userID = ?`,
+ },
+ { // Get all tags related to a task
+ Name: GetAllTagsRelatedToTask,
+ Cmd: `SELECT t.* FROM Tags t
+ JOIN TaskTags tt ON t.tagID = tt.tagID
+ WHERE tt.taskID = ?`,
+ },
+}
diff --git a/internal/tagging/tagging.go b/internal/intercept/tagging.go
index 8e8efe9..6479206 100644
--- a/internal/tagging/tagging.go
+++ b/internal/intercept/tagging.go
@@ -1,4 +1,4 @@
-package tagging
+package intercept
import (
"context"
@@ -11,7 +11,7 @@ import (
)
// gRPC interceptor to tag requests with a unique identifier
-func UnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+func Tagging(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
id, err := uuid.GenerateUUID()
if err != nil {
slog.Error("Unable to generate UUID for request", logging.ErrKey, err)
diff --git a/internal/logging/logging.go b/internal/logging/logging.go
index 91a9734..53155a3 100644
--- a/internal/logging/logging.go
+++ b/internal/logging/logging.go
@@ -43,6 +43,7 @@ func Setup(lvlStr, fmtStr, outStr string) error {
)
h = withTrackedContext(h, util.ReqIDKey, "request_id")
+ h = withTrackedContext(h, util.ProtoMethodKey, "proto_method")
h = withStackTrace(h)
slog.SetDefault(slog.New(h))
return errors.Join(outputErr, formatErr, levelErr)
diff --git a/internal/manualgen/manualgen.go b/internal/manualgen/manualgen.go
deleted file mode 100644
index 88a72e2..0000000
--- a/internal/manualgen/manualgen.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package main
-
-import (
- "log/slog"
- "os"
-
- "github.com/ChausseBenjamin/rafta/internal/app"
- "github.com/ChausseBenjamin/rafta/internal/logging"
- docs "github.com/urfave/cli-docs/v3"
-)
-
-func main() {
- a := app.Command()
-
- man, err := docs.ToManWithSection(a, 1)
- if err != nil {
- slog.Error("failed to generate markdown", logging.ErrKey, err)
- os.Exit(1)
- }
- os.Stdout.Write([]byte(man))
-}
diff --git a/internal/pb/mutate_admin.go b/internal/pb/mutate_admin.go
new file mode 100644
index 0000000..23710d5
--- /dev/null
+++ b/internal/pb/mutate_admin.go
@@ -0,0 +1,20 @@
+package pb
+
+import (
+ "context"
+
+ m "github.com/ChausseBenjamin/rafta/pkg/model"
+ "google.golang.org/protobuf/types/known/emptypb"
+)
+
+func (s *AdminServer) DeleteUser(ctx context.Context, id *m.UUID) (*emptypb.Empty, error) {
+ return nil, nil
+}
+
+func (s *AdminServer) UpdateUser(ctx context.Context, val *m.User) (*emptypb.Empty, error) {
+ return nil, nil
+}
+
+func (s *AdminServer) CreateUser(ctx context.Context, val *m.UserCreationMsg) (*m.User, error) {
+ return nil, nil
+}
diff --git a/internal/pb/mutate_user.go b/internal/pb/mutate_user.go
new file mode 100644
index 0000000..d4bc3ae
--- /dev/null
+++ b/internal/pb/mutate_user.go
@@ -0,0 +1,39 @@
+package pb
+
+import (
+ "context"
+
+ "github.com/ChausseBenjamin/rafta/internal/util"
+ m "github.com/ChausseBenjamin/rafta/pkg/model"
+ "google.golang.org/protobuf/types/known/emptypb"
+)
+
+func (s *UserServer) NewUser(ctx context.Context, val *m.UserData) (*m.User, error) {
+ ctx = context.WithValue(ctx, util.ProtoMethodKey, "NewUser")
+ return nil, nil
+}
+
+func (s *UserServer) UpdateUserInfo(ctx context.Context, val *m.User) (*emptypb.Empty, error) {
+ ctx = context.WithValue(ctx, util.ProtoMethodKey, "UpdateUserInfo")
+ return nil, nil
+}
+
+func (s *UserServer) DeleteUser(ctx context.Context, val *emptypb.Empty) (*emptypb.Empty, error) {
+ ctx = context.WithValue(ctx, util.ProtoMethodKey, "DeleteUser")
+ return nil, nil
+}
+
+func (s *UserServer) DeleteTask(ctx context.Context, val *m.UUID) (*emptypb.Empty, error) {
+ ctx = context.WithValue(ctx, util.ProtoMethodKey, "DeleteTask")
+ return nil, nil
+}
+
+func (s *UserServer) UpdateTask(ctx context.Context, val *m.TaskUpdate) (*emptypb.Empty, error) {
+ ctx = context.WithValue(ctx, util.ProtoMethodKey, "UpdateTask")
+ return nil, nil
+}
+
+func (s *UserServer) CreateTask(ctx context.Context, val *m.TaskData) (*m.Task, error) {
+ ctx = context.WithValue(ctx, util.ProtoMethodKey, "CreateTask")
+ return nil, nil
+}
diff --git a/internal/pb/query_admin.go b/internal/pb/query_admin.go
new file mode 100644
index 0000000..2f94aef
--- /dev/null
+++ b/internal/pb/query_admin.go
@@ -0,0 +1 @@
+package pb
diff --git a/internal/pb/query_user.go b/internal/pb/query_user.go
new file mode 100644
index 0000000..356a9c7
--- /dev/null
+++ b/internal/pb/query_user.go
@@ -0,0 +1,19 @@
+package pb
+
+import (
+ "context"
+
+ "github.com/ChausseBenjamin/rafta/internal/util"
+ m "github.com/ChausseBenjamin/rafta/pkg/model"
+ "google.golang.org/protobuf/types/known/emptypb"
+)
+
+func (s *UserServer) GetTask(ctx context.Context, id *m.UUID) (*m.Task, error) {
+ ctx = context.WithValue(ctx, util.ProtoMethodKey, "GetTask")
+ return nil, nil
+}
+
+func (s *UserServer) GetUserInfo(ctx context.Context, _ *emptypb.Empty) (*m.User, error) {
+ ctx = context.WithValue(ctx, util.ProtoMethodKey, "GetUserInfo")
+ return nil, nil
+}
diff --git a/internal/pb/server.go b/internal/pb/server.go
new file mode 100644
index 0000000..0e8ecb7
--- /dev/null
+++ b/internal/pb/server.go
@@ -0,0 +1,47 @@
+package pb
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+
+ "github.com/ChausseBenjamin/rafta/internal/db"
+ "github.com/ChausseBenjamin/rafta/internal/intercept"
+ m "github.com/ChausseBenjamin/rafta/pkg/model"
+ "google.golang.org/grpc"
+)
+
+var ErrOutOfBoundsPort = errors.New("given port is out of bounds (1024-65535)")
+
+// Implements ComsServer interface
+type UserServer struct {
+ db *db.Store
+ m.UnimplementedRaftaUserServer
+}
+
+type AdminServer struct {
+ db *db.Store
+ m.UnimplementedRaftaAdminServer
+}
+
+func NewUserServer(store *db.Store) *UserServer {
+ return &UserServer{db: store}
+}
+
+func NewAdminServer(store *db.Store) *AdminServer {
+ return &AdminServer{db: store}
+}
+
+// Setup creates a new gRPC with both services
+// and starts listening on the given port
+func Setup(ctx context.Context, store *db.Store) (*grpc.Server, error) {
+ slog.DebugContext(ctx, "Configuring gRPC server")
+ server := grpc.NewServer(grpc.ChainUnaryInterceptor(
+ intercept.Tagging,
+ ))
+
+ m.RegisterRaftaUserServer(server, NewUserServer(store))
+ m.RegisterRaftaAdminServer(server, NewAdminServer(store))
+
+ return server, nil
+}
diff --git a/internal/server/server.go b/internal/server/server.go
deleted file mode 100644
index 70e5861..0000000
--- a/internal/server/server.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package server
-
-import (
- "database/sql"
- "errors"
-
- m "github.com/ChausseBenjamin/rafta/internal/server/model"
-)
-
-var ErrOutOfBoundsPort = errors.New("port out of bounds")
-
-// Implements ComsServer interface
-type Service struct {
- store *sql.DB
- m.UnimplementedRaftaServer
-}
diff --git a/internal/server/setup.go b/internal/server/setup.go
deleted file mode 100644
index 17a17f3..0000000
--- a/internal/server/setup.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package server
-
-import (
- "database/sql"
- "fmt"
- "log/slog"
- "net"
-
- "github.com/ChausseBenjamin/rafta/internal/logging"
- m "github.com/ChausseBenjamin/rafta/internal/server/model"
- "github.com/ChausseBenjamin/rafta/internal/tagging"
- "google.golang.org/grpc"
-)
-
-func Setup(port int64, storage *sql.DB) (*grpc.Server, net.Listener, error) {
- lis, err := net.Listen(
- "tcp",
- fmt.Sprintf(":%d", port),
- )
- if err != nil {
- slog.Error("Unable to create listener", logging.ErrKey, err)
- return nil, nil, err
- }
-
- // FIXME: Implement Auth interceptor
-
- grpcServer := grpc.NewServer(
- grpc.ChainUnaryInterceptor(
- tagging.UnaryInterceptor,
- ),
- )
- raftaService := &Service{store: storage}
- m.RegisterRaftaServer(grpcServer, raftaService)
-
- return grpcServer, lis, nil
-}
diff --git a/internal/server/task.go b/internal/server/task.go
deleted file mode 100644
index d6b536a..0000000
--- a/internal/server/task.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package server
-
-import (
- "context"
- "log/slog"
-
- m "github.com/ChausseBenjamin/rafta/internal/server/model"
- "google.golang.org/protobuf/types/known/emptypb"
-)
-
-func (s Service) GetUserTasks(ctx context.Context, id *m.UserID) (*m.TaskList, error) {
- slog.ErrorContext(ctx, "GetUserTasks not implemented yet")
- // TODO: implement GetUserTasks
- return nil, nil
-}
-
-func (s Service) GetTask(ctx context.Context, id *m.TaskID) (*m.Task, error) {
- slog.ErrorContext(ctx, "GetTask not implemented yet")
- // TODO: implement GetTask
- return nil, nil
-}
-
-func (s Service) DeleteTask(ctx context.Context, id *m.TaskID) (*emptypb.Empty, error) {
- slog.ErrorContext(ctx, "DeleteTask not implemented yet")
- // TODO: implement DeleteTask
- return nil, nil
-}
-
-func (s Service) UpdateTask(ctx context.Context, t *m.Task) (*m.Task, error) {
- slog.ErrorContext(ctx, "UpdateTask not implemented yet")
- // TODO: implement UpdateTask
- return t, nil
-}
-
-func (s Service) CreateTask(ctx context.Context, data *m.TaskData) (*m.Task, error) {
- slog.ErrorContext(ctx, "CreateTask not implemented yet")
- // TODO: implement CreateTask
- return nil, nil
-}
diff --git a/internal/server/user.go b/internal/server/user.go
deleted file mode 100644
index b5f5a6d..0000000
--- a/internal/server/user.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package server
-
-import (
- "context"
- "log/slog"
-
- m "github.com/ChausseBenjamin/rafta/internal/server/model"
- "google.golang.org/protobuf/types/known/emptypb"
-)
-
-func (s Service) GetAllUsers(ctx context.Context, empty *emptypb.Empty) (*m.UserList, error) {
- slog.ErrorContext(ctx, "GetAllUsers not implemented yet")
- // TODO: implement GetAllUsers
- return nil, nil
-}
-
-func (s Service) GetUser(ctx context.Context, id *m.UserID) (*m.User, error) {
- slog.ErrorContext(ctx, "GetUser not implemented yet")
- // TODO: implement GetUser
- return nil, nil
-}
-
-func (s Service) DeleteUser(ctx context.Context, id *m.UserID) (*emptypb.Empty, error) {
- slog.ErrorContext(ctx, "DeleteUser not implemented yet")
- // TODO: implement DeleteUser
- return nil, nil
-}
-
-func (s Service) UpdateUser(ctx context.Context, u *m.User) (*m.User, error) {
- slog.ErrorContext(ctx, "UpdateUser not implemented yet")
- // TODO: implement UpdateUser
- return nil, nil
-}
-
-func (s Service) CreateUser(ctx context.Context, data *m.UserData) (*m.User, error) {
- slog.ErrorContext(ctx, "CreateUser not implemented yet")
- // TODO: implement CreateUser
- return nil, nil
-}
diff --git a/internal/storage/db.go b/internal/storage/db.go
deleted file mode 100644
index 06fe31c..0000000
--- a/internal/storage/db.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package storage
-
-import (
- "context"
- "database/sql"
- "log/slog"
-
- "github.com/ChausseBenjamin/rafta/internal/util"
-)
-
-func GetDB(ctx context.Context) *sql.DB {
- db, ok := ctx.Value(util.DBKey).(*sql.DB)
- if !ok {
- slog.Error("Unable to retrieve database from context")
- return nil
- }
- return db
-}
diff --git a/internal/storage/schema.go b/internal/storage/schema.go
deleted file mode 100644
index 042c09b..0000000
--- a/internal/storage/schema.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package storage
-
-func opts() string {
- return "?_foreign_keys=on&_journal_mode=WAL"
-}
-
-func schema() string {
- return `
-CREATE TABLE User (
- UserID INTEGER PRIMARY KEY AUTOINCREMENT,
- Name TEXT NOT NULL,
- Email TEXT NOT NULL UNIQUE
-);
-
-CREATE TABLE Task (
- TaskID INTEGER PRIMARY KEY AUTOINCREMENT,
- Title TEXT NOT NULL,
- Description TEXT NOT NULL,
- Due DATE NOT NULL,
- Do DATE NOT NULL,
- Owner INTEGER NOT NULL,
- FOREIGN KEY (Owner) REFERENCES User(UserID) ON DELETE CASCADE
-);
-
-CREATE TABLE Tag (
- TagID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
- Name TEXT NOT NULL UNIQUE
-);
-
-CREATE TABLE TaskTag (
- TaskUUID INTEGER NOT NULL,
- TagID INTEGER NOT NULL,
- PRIMARY KEY (TaskUUID, TagID),
- FOREIGN KEY (TaskUUID) REFERENCES Task(UUID) ON DELETE CASCADE,
- FOREIGN KEY (TagID) REFERENCES Tag(TagID) ON DELETE CASCADE
-);
-`
-}
diff --git a/internal/storage/setup.go b/internal/storage/setup.go
deleted file mode 100644
index f103b15..0000000
--- a/internal/storage/setup.go
+++ /dev/null
@@ -1,201 +0,0 @@
-package storage
-
-import (
- "database/sql"
- "errors"
- "fmt"
- "log/slog"
- "os"
- "strings"
-
- _ "github.com/mattn/go-sqlite3"
-)
-
-var (
- ErrIntegrityCheckFailed = errors.New("integrity check failed")
- ErrForeignKeysDisabled = errors.New("foreign_keys pragma is not enabled")
- ErrJournalModeInvalid = errors.New("journal_mode is not wal")
- ErrSchemaMismatch = errors.New("schema does not match expected definition")
-)
-
-// Setup opens the SQLite DB at path, verifies its integrity and schema,
-// and returns the valid DB handle. On any error, it backs up the old file
-// (if it exists) and calls genDB() to initialize a valid schema.
-func Setup(path string) (*sql.DB, error) {
- _, statErr := os.Stat(path)
- exists := statErr == nil
-
- // If file doesn't exist, generate new DB.
- if !exists {
- return genDB(path)
- }
-
- db, err := sql.Open("sqlite3", path+opts())
- if err != nil {
- slog.Error("failed to open DB", "error", err)
- backupFile(path)
- return genDB(path)
- }
-
- // Integrity check.
- var integrity string
- if err = db.QueryRow("PRAGMA integrity_check;").Scan(&integrity); err != nil {
- slog.Error("integrity check query failed", "error", err)
- db.Close()
- backupFile(path)
- return genDB(path)
- }
- if integrity != "ok" {
- slog.Error("integrity check failed", "error", ErrIntegrityCheckFailed)
- db.Close()
- backupFile(path)
- return genDB(path)
- }
-
- // Validate schema and pragmas.
- if err = validateSchema(db); err != nil {
- slog.Error("schema validation failed", "error", err)
- db.Close()
- backupFile(path)
- return genDB(path)
- }
-
- return db, nil
-}
-
-// validateSchema verifies that required pragmas and table definitions are set.
-func validateSchema(db *sql.DB) error {
- // Check PRAGMA foreign_keys = on.
- var fk int
- if err := db.QueryRow("PRAGMA foreign_keys;").Scan(&fk); err != nil {
- return err
- }
- if fk != 1 {
- return ErrForeignKeysDisabled
- }
-
- // Check PRAGMA journal_mode = wal.
- var jm string
- if err := db.QueryRow("PRAGMA journal_mode;").Scan(&jm); err != nil {
- return err
- }
- if strings.ToLower(jm) != "wal" {
- return ErrJournalModeInvalid
- }
-
- // Define required table definitions (as substrings in lower-case).
- type tableCheck struct {
- name string
- substrings []string
- }
-
- checks := []tableCheck{
- {
- name: "User",
- substrings: []string{
- "create table user",
- "userid", "integer", "primary key", "autoincrement",
- "name", "text", "not null",
- "email", "text", "not null", "unique",
- },
- },
- {
- name: "Task",
- substrings: []string{
- "create table task",
- "taskid", "integer", "primary key", "autoincrement",
- "title", "not null",
- "description", "not null",
- "due", "date", "not null",
- "do", "date", "not null",
- "owner", "integer", "not null",
- "foreign key", "references user",
- },
- },
- {
- name: "Tag",
- substrings: []string{
- "create table tag",
- "tagid", "integer", "primary key", "autoincrement",
- "name", "text", "not null", "unique",
- },
- },
- {
- name: "TaskTag",
- substrings: []string{
- "create table tasktag",
- "taskuuid", "integer", "not null",
- "tagid", "integer", "not null",
- "primary key",
- "foreign key", "references task",
- "foreign key", "references tag",
- },
- },
- }
-
- for _, chk := range checks {
- sqlDef, err := fetchTableSQL(db, chk.name)
- if err != nil {
- return fmt.Errorf("failed to fetch definition for table %s: %w", chk.name, err)
- }
- lc := strings.ToLower(sqlDef)
- for _, substr := range chk.substrings {
- if !strings.Contains(lc, substr) {
- return fmt.Errorf("%w: table %s missing %q", ErrSchemaMismatch, chk.name, substr)
- }
- }
- }
-
- return nil
-}
-
-func fetchTableSQL(db *sql.DB, table string) (string, error) {
- var sqlDef sql.NullString
- err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&sqlDef)
- if err != nil {
- return "", err
- }
- if !sqlDef.Valid {
- return "", fmt.Errorf("no SQL definition found for table %s", table)
- }
- return sqlDef.String, nil
-}
-
-// backupFile renames the existing file by appending a ".bak" suffix.
-func backupFile(path string) {
- backupPath := path + ".bak"
- // If backupPath exists, append a timestamp.
- if _, err := os.Stat(backupPath); err == nil {
- backupPath = fmt.Sprintf("%s.%d.bak", path, os.Getpid())
- }
- if err := os.Rename(path, backupPath); err != nil {
- slog.Error("failed to backup file", "error", err, "original", path, "backup", backupPath)
- } else {
- slog.Info("backed up corrupt DB", "original", path, "backup", backupPath)
- }
-}
-
-// genDB creates a new database at path with the valid schema.
-func genDB(path string) (*sql.DB, error) {
- db, err := sql.Open("sqlite3", path)
- if err != nil {
- slog.Error("failed to create DB", "error", err)
- return nil, err
- }
-
- // Set pragmas.
- if _, err := db.Exec("PRAGMA foreign_keys = on; PRAGMA journal_mode = wal;"); err != nil {
- slog.Error("failed to set pragmas", "error", err)
- db.Close()
- return nil, err
- }
-
- if _, err := db.Exec(schema()); err != nil {
- slog.Error("failed to initialize schema", "error", err)
- db.Close()
- return nil, err
- }
-
- slog.Info("created new blank DB with valid schema", "path", path)
- return db, nil
-}
diff --git a/internal/util/context.go b/internal/util/context.go
index e7cbab2..978dfeb 100644
--- a/internal/util/context.go
+++ b/internal/util/context.go
@@ -5,4 +5,5 @@ type ContextKey uint8
const (
DBKey ContextKey = iota
ReqIDKey
+ ProtoMethodKey
)
diff --git a/internal/server/model/.gitignore b/pkg/model/.gitignore
index d6b7ef3..d6b7ef3 100644
--- a/internal/server/model/.gitignore
+++ b/pkg/model/.gitignore
diff --git a/resources/Dockerfile b/resources/Dockerfile
new file mode 100644
index 0000000..89c1ac3
--- /dev/null
+++ b/resources/Dockerfile
@@ -0,0 +1,23 @@
+FROM golang:latest AS setup
+WORKDIR /app
+
+COPY go.* ./
+COPY cmd cmd
+COPY internal internal
+
+RUN go mod download && go mod verify
+
+RUN CGO_ENABLED=0 GOOS=linux go build -o /application cmd/songlinkr/main.go
+
+FROM alpine:latest AS compressor
+
+RUN apk add --no-cache upx
+
+COPY --from=setup
+
+FROM scratch AS package
+
+COPY --from=setup /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
+COPY --from=setup /application /application
+
+CMD ["/application"]
diff --git a/resources/local_dev.sh b/resources/local_dev.sh
index 68bb46b..8fe28c2 100755
--- a/resources/local_dev.sh
+++ b/resources/local_dev.sh
@@ -4,8 +4,9 @@ env_vars=$(cat << EOF
LOG_LEVEL=debug
LOG_FORMAT=plain
LOG_OUTPUT=stdout
- LISTEN_PORT=1234
+ LISTEN_PORT=1157
DATABASE_PATH=runtime/store.db
+ GRACEFUL_TIMEOUT=1ms
EOF
)
diff --git a/resources/schema.proto b/resources/schema.proto
index 5b34332..7480c55 100644
--- a/resources/schema.proto
+++ b/resources/schema.proto
@@ -3,7 +3,11 @@ syntax = "proto3";
import "protobuf/src/google/protobuf/empty.proto";
import "protobuf/src/google/protobuf/timestamp.proto";
-option go_package = "github.com/ChausseBenjamin/rafta/internal/server/model";
+option go_package = "github.com/ChausseBenjamin/rafta/pkg/model";
+
+message UUID {
+ string value = 1;
+}
enum TaskState {
TASK_UNDEFINED = 0;
@@ -13,10 +17,6 @@ enum TaskState {
TASK_BLOCKED = 4;
}
-message UserID {
- string uuid = 1;
-}
-
message UserData {
string name = 1;
string email = 2;
@@ -25,30 +25,47 @@ message UserData {
}
message User {
- UserID id = 1;
+ UUID id = 1;
UserData data = 2;
}
-message TaskID {
- string uuid = 1;
+// Used only by admin users to create users manually since he cannot use it's
+// own jwt to create a non admin user.
+message UserCreationMsg {
+ UserData userData = 1;
+ string userSecret = 2;
+}
+
+message TaskRecurrence {
+ string cron = 1;
+ bool active = 2;
}
message TaskData {
string title = 1;
- Description desc = 2; // markdown
- uint32 priority = 3;
+ string desc = 2; // markdown
+ // Intentionally vague for easy client implementation:
+ uint32 priority = 3; // 0=undefined, 1=highest, 0xFFFFFFFF=lowest
TaskState state = 4;
- google.protobuf.Timestamp created_on = 5;
- google.protobuf.Timestamp last_updated = 6;
- repeated string tags = 7;
+ TaskRecurrence recurrence = 5;
+ google.protobuf.Timestamp created_on = 6;
+ google.protobuf.Timestamp last_updated = 7;
+ repeated string tags = 8;
}
-message Description {
- string data = 1;
+message TaskUpdate {
+ UUID id = 1;
+ oneof field {
+ string title = 2;
+ string desc = 3;
+ uint32 priority = 4;
+ TaskState state = 5;
+ TaskRecurrence recurrence = 6;
+ }
}
message Task {
- TaskID id = 1;
+ UUID id = 1;
TaskData data = 2;
}
@@ -60,20 +77,34 @@ message UserList {
repeated User users = 1;
}
-service Rafta {
- // Retrieval
- rpc GetUserTasks(UserID) returns (TaskList);
- rpc GetAllUsers(google.protobuf.Empty) returns (UserList);
- rpc GetUser(UserID) returns (User);
- rpc GetTask(TaskID) returns (Task);
+// Accessible to all users once authenticated
+// User can only manipulate its own data
+service RaftaUser {
+ // Retrieval
+ rpc GetUserInfo(google.protobuf.Empty) returns (User);
+ rpc GetTask(UUID) returns (Task);
+
+ // User Manipulation
+ rpc NewUser(UserData) returns (User);
+ rpc UpdateUserInfo(User) returns (google.protobuf.Empty);
+ // Input is empty because user should be authenticated via JWT
+ rpc DeleteUser(google.protobuf.Empty) returns (google.protobuf.Empty);
// Task Manipulation
- rpc DeleteTask(TaskID) returns (google.protobuf.Empty);
- rpc UpdateTask(Task) returns (Task);
+ rpc DeleteTask(UUID) returns (google.protobuf.Empty);
+ rpc UpdateTask(TaskUpdate) returns (google.protobuf.Empty);
rpc CreateTask(TaskData) returns (Task);
+}
+
+// Accessible only to users with the admin role
+service RaftaAdmin {
+ // Retrieval
+ rpc GetUserTasks(UUID) returns (TaskList);
+ rpc GetAllUsers(google.protobuf.Empty) returns (UserList);
+ rpc GetUser(UUID) returns (User);
// User Manipulation
- rpc DeleteUser(UserID) returns (google.protobuf.Empty);
- rpc UpdateUser(User) returns (User);
- rpc CreateUser(UserData) returns (User);
+ rpc DeleteUser(UUID) returns (google.protobuf.Empty);
+ rpc UpdateUser(User) returns (google.protobuf.Empty);
+ rpc CreateUser(UserCreationMsg) returns (User);
}