From f36f77472a82d6ebfac153aed6d17f154ae239a6 Mon Sep 17 00:00:00 2001 From: Benjamin Chausse Date: Sat, 22 Feb 2025 09:59:10 -0500 Subject: Good foundations --- Makefile | 4 +- internal/app/action.go | 110 +++++++++++++--- internal/app/flags.go | 33 +++-- internal/autogenerate/manualgen.go | 32 +++++ internal/db/data_validation.go | 67 ++++++++++ internal/db/schema_initialisation.go | 130 ++++++++++++++++++ internal/db/setup.go | 234 +++++++++++++++++++++++++++++++++ internal/db/transaction_definitions.go | 108 +++++++++++++++ internal/intercept/tagging.go | 23 ++++ internal/logging/logging.go | 1 + internal/manualgen/manualgen.go | 21 --- internal/pb/mutate_admin.go | 20 +++ internal/pb/mutate_user.go | 39 ++++++ internal/pb/query_admin.go | 1 + internal/pb/query_user.go | 19 +++ internal/pb/server.go | 47 +++++++ internal/server/model/.gitignore | 2 - internal/server/server.go | 16 --- internal/server/setup.go | 36 ----- internal/server/task.go | 39 ------ internal/server/user.go | 39 ------ internal/storage/db.go | 18 --- internal/storage/schema.go | 38 ------ internal/storage/setup.go | 201 ---------------------------- internal/tagging/tagging.go | 23 ---- internal/util/context.go | 1 + pkg/model/.gitignore | 2 + resources/Dockerfile | 23 ++++ resources/local_dev.sh | 3 +- resources/schema.proto | 85 ++++++++---- 30 files changed, 918 insertions(+), 497 deletions(-) create mode 100644 internal/autogenerate/manualgen.go create mode 100644 internal/db/data_validation.go create mode 100644 internal/db/schema_initialisation.go create mode 100644 internal/db/setup.go create mode 100644 internal/db/transaction_definitions.go create mode 100644 internal/intercept/tagging.go delete mode 100644 internal/manualgen/manualgen.go create mode 100644 internal/pb/mutate_admin.go create mode 100644 internal/pb/mutate_user.go create mode 100644 internal/pb/query_admin.go create mode 100644 internal/pb/query_user.go create mode 100644 internal/pb/server.go delete mode 100644 internal/server/model/.gitignore delete mode 100644 internal/server/server.go delete mode 100644 internal/server/setup.go delete mode 100644 internal/server/task.go delete mode 100644 internal/server/user.go delete mode 100644 internal/storage/db.go delete mode 100644 internal/storage/schema.go delete mode 100644 internal/storage/setup.go delete mode 100644 internal/tagging/tagging.go create mode 100644 pkg/model/.gitignore create mode 100644 resources/Dockerfile 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/intercept/tagging.go b/internal/intercept/tagging.go new file mode 100644 index 0000000..6479206 --- /dev/null +++ b/internal/intercept/tagging.go @@ -0,0 +1,23 @@ +package intercept + +import ( + "context" + "log/slog" + + "github.com/ChausseBenjamin/rafta/internal/logging" + "github.com/ChausseBenjamin/rafta/internal/util" + "github.com/hashicorp/go-uuid" + "google.golang.org/grpc" +) + +// gRPC interceptor to tag requests with a unique identifier +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) + } + ctx = context.WithValue(ctx, util.ReqIDKey, id) + slog.DebugContext(ctx, "Tagging request with UUID", "value", id) + + return handler(ctx, req) +} 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/model/.gitignore b/internal/server/model/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/internal/server/model/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore 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/tagging/tagging.go b/internal/tagging/tagging.go deleted file mode 100644 index 8e8efe9..0000000 --- a/internal/tagging/tagging.go +++ /dev/null @@ -1,23 +0,0 @@ -package tagging - -import ( - "context" - "log/slog" - - "github.com/ChausseBenjamin/rafta/internal/logging" - "github.com/ChausseBenjamin/rafta/internal/util" - "github.com/hashicorp/go-uuid" - "google.golang.org/grpc" -) - -// gRPC interceptor to tag requests with a unique identifier -func UnaryInterceptor(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) - } - ctx = context.WithValue(ctx, util.ReqIDKey, id) - slog.DebugContext(ctx, "Tagging request with UUID", "value", id) - - return handler(ctx, req) -} 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/pkg/model/.gitignore b/pkg/model/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/pkg/model/.gitignore @@ -0,0 +1,2 @@ +* +!.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); } -- cgit v1.2.3