From 6569a399e37b58f7dcefeed647e233c5be149833 Mon Sep 17 00:00:00 2001 From: ae Date: Thu, 27 Mar 2025 15:18:16 +0200 Subject: [PATCH] feat: initial db layer (gorm models + handlers) --- server/go.mod | 17 +- server/go.sum | 36 +++- server/internal/handler.go | 1 - server/internal/middleware.go | 1 - server/internal/model.go | 1 - server/internal/server.go | 17 -- server/internal/util.go | 36 ---- server/migrations/0001_initial.up.sql | 30 ++++ server/pkg/database/migration.go | 77 +++++++++ server/pkg/database/note.go | 230 ++++++++++++++++++++++++++ server/pkg/database/user.go | 104 ++++++++++++ 11 files changed, 482 insertions(+), 68 deletions(-) delete mode 100644 server/internal/handler.go delete mode 100644 server/internal/middleware.go delete mode 100644 server/internal/model.go delete mode 100644 server/internal/server.go delete mode 100644 server/internal/util.go create mode 100644 server/migrations/0001_initial.up.sql create mode 100644 server/pkg/database/migration.go create mode 100644 server/pkg/database/note.go create mode 100644 server/pkg/database/user.go diff --git a/server/go.mod b/server/go.mod index cd60f1b..ef156e3 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,15 +3,24 @@ module git.umbrella.haus/ae/notatest go 1.24.1 require ( - github.com/go-chi/chi/v5 v5.2.1 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/caarlos0/env v3.5.0+incompatible + github.com/rs/zerolog v1.34.0 + gorm.io/driver/postgres v1.5.11 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.4 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/rs/zerolog v1.34.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect golang.org/x/crypto v0.36.0 // indirect + golang.org/x/sync v0.12.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect - gorm.io/gorm v1.25.12 // indirect ) diff --git a/server/go.sum b/server/go.sum index ed3b4f6..69564de 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,9 +1,18 @@ +github.com/caarlos0/env v3.5.0+incompatible h1:Yy0UN8o9Wtr/jGHZDpCBLpNrzcFLLM2yixi/rBrKyJs= +github.com/caarlos0/env v3.5.0+incompatible/go.mod h1:tdCsowwCzMLdkqRYDlHpZCp2UooDD3MspDBjZ2AD02Y= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= -github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -15,14 +24,21 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= -github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -30,7 +46,11 @@ golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/server/internal/handler.go b/server/internal/handler.go deleted file mode 100644 index 5bf0569..0000000 --- a/server/internal/handler.go +++ /dev/null @@ -1 +0,0 @@ -package internal diff --git a/server/internal/middleware.go b/server/internal/middleware.go deleted file mode 100644 index 5bf0569..0000000 --- a/server/internal/middleware.go +++ /dev/null @@ -1 +0,0 @@ -package internal diff --git a/server/internal/model.go b/server/internal/model.go deleted file mode 100644 index 5bf0569..0000000 --- a/server/internal/model.go +++ /dev/null @@ -1 +0,0 @@ -package internal diff --git a/server/internal/server.go b/server/internal/server.go deleted file mode 100644 index c45882a..0000000 --- a/server/internal/server.go +++ /dev/null @@ -1,17 +0,0 @@ -package internal - -import "github.com/rs/zerolog/log" - -var ( - jwtSecret, dbURL string -) - -func init() { - InitLogger() - jwtSecret = EnvOrExit("JWT_SECRET") - dbURL = EnvOrExit("POSTGRES_URL") - log.Debug().Msg("Initialization completed") - // TODO: run migrations here if necessary -} - -func Run() {} diff --git a/server/internal/util.go b/server/internal/util.go deleted file mode 100644 index 94e4f3b..0000000 --- a/server/internal/util.go +++ /dev/null @@ -1,36 +0,0 @@ -package internal - -import ( - "os" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -func EnvOrExit(key string) string { - value := os.Getenv(key) - if value == "" { - log.Fatal().Msgf("Required env. variable '%s' is empty or missing", key) - os.Exit(1) - } - log.Debug().Msgf("Read env: %s='%s'", key, value) - return value -} - -func InitLogger() { - logLevel := os.Getenv("LOG_LEVEL") - level, err := zerolog.ParseLevel(logLevel) - if err != nil { - // Default to INFO - level = zerolog.InfoLevel - } - zerolog.SetGlobalLevel(level) - - if os.Getenv("ENV") == "development" { - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) - } else { - log.Logger = log.Output(os.Stderr) // JSON to stdout/stderr - } - - log.Debug().Msg("Logger initialized") -} diff --git a/server/migrations/0001_initial.up.sql b/server/migrations/0001_initial.up.sql new file mode 100644 index 0000000..d5b3a52 --- /dev/null +++ b/server/migrations/0001_initial.up.sql @@ -0,0 +1,30 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE notes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE note_versions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + title TEXT NOT NULL, + content TEXT NOT NULL, + version_number INT NOT NULL, + content_hash TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_note_version_unique ON note_versions (note_id, version_number); +CREATE UNIQUE INDEX idx_users_username ON users(username); +CREATE INDEX idx_note_versions_note ON note_versions(note_id); +CREATE INDEX idx_note_versions_number ON note_versions(version_number DESC); diff --git a/server/pkg/database/migration.go b/server/pkg/database/migration.go new file mode 100644 index 0000000..5342884 --- /dev/null +++ b/server/pkg/database/migration.go @@ -0,0 +1,77 @@ +package database + +import ( + "fmt" + "io/fs" + "slices" + "sort" + "strconv" + "strings" + + "gorm.io/gorm" +) + +// Atomically run migrations found from the given filesystem (with the format `*.up.sql`). +// Skips already applied migrations, but fails on malformed or iisconfigured entries. Notably atomic +// execution must be utilized to automatically roll back any partially applied migrations. +func RunMigrations(db *gorm.DB, migrationsFS fs.FS) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY + ) + `).Error; err != nil { + return err + } + + // Query already applied migrations to prevent duplicate execution + var applied []int + if err := tx.Table("schema_migrations").Pluck("version", &applied).Error; err != nil { + return err + } + + migrationFiles, err := fs.Glob(migrationsFS, "*.up.sql") + if err != nil { + return err + } + + for _, f := range sortMigrations(migrationFiles) { + version, err := strconv.Atoi(strings.Split(f, "_")[0]) + if err != nil { + return fmt.Errorf("invalid migration filename: %s", f) + } + + if slices.Contains(applied, version) { + continue + } + + sql, err := fs.ReadFile(migrationsFS, f) + if err != nil { + return err + } + + if err := tx.Exec(string(sql)).Error; err != nil { + return err + } + + if err := tx.Exec(` + INSERT INTO schema_migrations (version) VALUES (?) + `, version).Error; err != nil { + return err + } + } + + return nil + }) +} + +// Sort the given migration files to ascending order based on the filename integer prefix to then +// be executed sequentially. +func sortMigrations(files []string) []string { + sort.Slice(files, func(i, j int) bool { + v1, _ := strconv.Atoi(strings.Split(files[i], "_")[0]) + v2, _ := strconv.Atoi(strings.Split(files[j], "_")[0]) + return v1 < v2 + }) + return files +} diff --git a/server/pkg/database/note.go b/server/pkg/database/note.go new file mode 100644 index 0000000..9ec1c90 --- /dev/null +++ b/server/pkg/database/note.go @@ -0,0 +1,230 @@ +package database + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "gorm.io/gorm" +) + +var ( + ErrUnauthorizedOrMissing = errors.New("item not found or unauthorized") + ErrVersionNotFound = errors.New("version not found") + ErrDeletionFailed = errors.New("deletion failed") +) + +type Note struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` + UserID string `gorm:"type:uuid;not null;index"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + Versions []NoteVersion `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"` +} + +type NoteVersion struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` + NoteID string `gorm:"type:uuid;not null;index"` + Title string `gorm:"type:varchar(255);not null"` + Content string `gorm:"type:text;not null"` // TOAST will automatically compress at >2KB + VersionNumber int `gorm:"not null"` + ContentHash string `gorm:"type:varchar(64);not null;index"` + CreatedAt time.Time `gorm:"autoCreateTime"` +} + +// Helper hook that runs automatically before a new note version is created. It calculates a +// composite SHA256 hash of the title-content combination and looks up thte valid version +// number based on any previous entries in the table. +func (nv *NoteVersion) BeforeCreate(tx *gorm.DB) (err error) { + combined := nv.Title + "\n" + nv.Content + hash := sha256.Sum256([]byte(combined)) + nv.ContentHash = hex.EncodeToString(hash[:]) + + var maxVersion int + err = tx.Model(&NoteVersion{}). + Where("note_id = ?", nv.NoteID). + Select("COALESCE(MAX(version_number), 0)"). + Scan(&maxVersion). + Error + + if err != nil { + return err + } + + nv.VersionNumber = maxVersion + 1 + return nil +} + +// NOTE: User ID should always be injected to the requests by a middleware (and not taken from +// a user input). + +// Create a new note and its initial version. Might return an error if the creation transaction +// fails. On success returns a pointer to the newly created note object. +func CreateNote(db *gorm.DB, userID string, title string, content string) (*Note, error) { + note := Note{ + UserID: userID, + } + + err := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(¬e).Error; err != nil { + return err + } + + return tx.Create(&NoteVersion{ + NoteID: note.ID, + Title: title, + Content: content, + }).Error + }) + + return ¬e, err +} + +// Get a list of `limit` notes belonging to the given user and their historical versions (preloaded +// version numbers). +func ListNotes(db *gorm.DB, userID string, page int, limit int) ([]Note, error) { + var notes []Note + offset := (page - 1) * limit + + err := db. + Preload("Versions", func(db *gorm.DB) *gorm.DB { + return db.Order("note_versions.version_number DESC").Limit(1) + }). + Where("user_id = ?", userID). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(¬es). + Error + + return notes, err +} + +// Get a single note with its preloaded version numbers. +func GetNote(db *gorm.DB, userID string, noteID string) (*Note, error) { + var note Note + err := db. + Preload("Versions", func(db *gorm.DB) *gorm.DB { + return db.Order("note_versions.version_number DESC") + }). + Where("id = ? AND user_id = ?", noteID, userID). + First(¬e). + Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUnauthorizedOrMissing + } + + return ¬e, err +} + +// Get all the linked historical versions of the given note. +func GetNoteVersions(db *gorm.DB, userID string, noteID string, page int, limit int) ([]NoteVersion, error) { + var versions []NoteVersion + + var note Note + if err := db. + Select("id"). + Where("id = ? AND user_id = ?", noteID, userID). + First(¬e). + Error; err != nil { + return nil, ErrUnauthorizedOrMissing + } + + offset := (page - 1) * limit + err := db. + Where("note_id = ?", noteID). + Order("version_number DESC"). + Offset(offset). + Limit(limit). + Find(&versions). + Error + + return versions, err +} + +// Get a specific historical version of the given note. +func GetNoteVersion(db *gorm.DB, userID string, noteID string, versionNumber int) (*NoteVersion, error) { + var version NoteVersion + + // Ownership check via note + var note Note + if err := db. + Select("id"). + Where("id = ? AND user_id = ?", noteID, userID). + First(¬e). + Error; err != nil { + return nil, ErrUnauthorizedOrMissing + } + + err := db. + Where("note_id = ? AND version_number = ?", noteID, versionNumber). + First(&version). + Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrVersionNotFound + } + + return &version, err +} + +// Update an existing note by creating a new version. If the composite SHA256 hash of the new +// version contents matches with an already stored version, the new version will be skipped. +func UpdateNote(db *gorm.DB, userID string, noteID string, newTitle string, newContent string) error { + return db.Transaction(func(tx *gorm.DB) error { + var note Note + if err := tx. + Where("id = ? AND user_id = ?", noteID, userID). + First(¬e). + Error; err != nil { + return ErrUnauthorizedOrMissing + } + + var latest NoteVersion + if err := tx. + Where("note_id = ?", noteID). + Order("version_number DESC"). + First(&latest). + Error; err != nil { + return err + } + + combined := newTitle + "\n" + newContent + newHash := sha256.Sum256([]byte(combined)) + if hex.EncodeToString(newHash[:]) == latest.ContentHash { + return nil // No changes + } + + return tx.Create(&NoteVersion{ + NoteID: noteID, + Title: newTitle, + Content: newContent, + }).Error + }) +} + +// Remove a note and its versions permanently (hard delete). +func DeleteNote(db *gorm.DB, userID string, noteID string) error { + return db.Transaction(func(tx *gorm.DB) error { + var note Note + if err := tx. + Where("id = ? AND user_id = ?", noteID, userID). + First(¬e). + Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrUnauthorizedOrMissing + } + return err + } + + // Delete note (cascades to versions) + if err := tx.Delete(¬e).Error; err != nil { + return ErrDeletionFailed + } + + return nil + }) +} diff --git a/server/pkg/database/user.go b/server/pkg/database/user.go new file mode 100644 index 0000000..a3920a6 --- /dev/null +++ b/server/pkg/database/user.go @@ -0,0 +1,104 @@ +package database + +import ( + "errors" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type User struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` + Username string `gorm:"unique;not null;index"` + PasswordHash string `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + Notes []Note `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` +} + +var ( + ErrUserNotFound = errors.New("user not found") + ErrUsernameTaken = errors.New("username is not available") + ErrInvalidCredentials = errors.New("invalid credentials") +) + +// Create a new user using the given credentials (password hashed with `bcrypt`). +func CreateUser(db *gorm.DB, username string, password string) (*User, error) { + // Username availability + var count int64 + db.Model(&User{}).Where("username = ?", username).Count(&count) + if count > 0 { + return nil, ErrUsernameTaken + } + + // Password hashing, max length (72 bytes) validated in the handler layer + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + user := User{ + Username: username, + PasswordHash: string(hash), + } + + err = db.Create(&user).Error + return &user, err +} + +// Get a user with their UUID. +func GetUserByID(db *gorm.DB, userID string) (*User, error) { + var user User + err := db.First(&user, "id = ?", userID).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return &user, err +} + +// Get a user with their username. +func GetUserByUsername(db *gorm.DB, username string) (*User, error) { + var user User + err := db.First(&user, "username = ?", username).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return &user, err +} + +// Verify the given credentials by fetching the hashed password from the database and comparing it +// with the given one using `bcrypt`. +func VerifyUserCredentials(db *gorm.DB, username string, password string) (*User, error) { + user, err := GetUserByUsername(db, username) + if err != nil { + return nil, err // ErrUserNotFound + } + + err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) + if err != nil { + return nil, ErrInvalidCredentials + } + + return user, nil +} + +// Update the password of the given user (identified with UUID). +func UpdatePassword(db *gorm.DB, userID string, newPassword string) error { + // Once again (length) validation should be done in the handler layer + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + return db.Model(&User{}). + Where("id = ?", userID). + Update("password_hash", string(hash)). + Error +} + +// Remove a user and their notes (and notes' versions, hard delete with cascade). +func DeleteUser(db *gorm.DB, userID string) error { + return db.Transaction(func(tx *gorm.DB) error { + return tx.Delete(&User{}, "id = ?", userID).Error + }) +}