ae 62b1a58e56
feat!: trimming & logic/schema improvements
- build: somewhat polished dockerization setup
- build: io/fs migrations with `golang-migrate`
- feat: automatic init. admin account creation (.env creds)
- feat(routers): combined user & token routers into single auth router
- feat(routers): improved route layouts (`Routes`)
- feat(middlewares): removed redundant `userCtx` middleware
- fix(schema): note <-> note_versions relation (versioning)
- feat(queries): removed redundant rollback functionality
- feat(queries): combined duplicate version check & insertion/creation
- tests: decreased redundancy by removing 'unnecessary' unit tests
- refactor: hid internal packages behind `server/internal`
- docs: notes & auth handler comments
2025-04-09 01:58:38 +03:00

332 lines
11 KiB
Go

package service
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"git.umbrella.haus/ae/notatest/internal/data"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
const (
titleMaxLength = 150
initVersionTitle = "Untitled"
initVersionContent = ""
)
// Note object context key for incoming requests (handled my middlewares). Only `*data.GetFullNoteRow`
// type objects should be stored behind this key for consistency.
type noteCtxKey struct{}
// Note version object context key for incoming requests (handled by middlewares). Only
// `*data.GetVersionRow` type objects should be stored behind this key for consistency.
type versionCtxKey struct{}
// Mockable database operations interface
type NoteStore interface {
CreateNote(ctx context.Context, userID uuid.UUID) (data.Note, error)
DeleteNote(ctx context.Context, arg data.DeleteNoteParams) error
GetFullNote(ctx context.Context, noteID uuid.UUID) (data.GetFullNoteRow, error)
ListNotes(ctx context.Context, arg data.ListNotesParams) ([]data.ListNotesRow, error)
CreateNoteVersion(ctx context.Context, arg data.CreateNoteVersionParams) error
GetVersion(ctx context.Context, arg data.GetVersionParams) (data.GetVersionRow, error)
GetVersionHistory(ctx context.Context, arg data.GetVersionHistoryParams) ([]data.GetVersionHistoryRow, error)
}
// Chi HTTP router for notes related CRUD actions.
type notesResource struct {
JWTSecret string
Notes NoteStore
}
func (rs notesResource) Routes() chi.Router {
r := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Use(requireAccessToken(rs.JWTSecret)) // JWT claims -> ctx.
r.Post("/", rs.Create) // POST /notes - create new note
r.Get("/", rs.ListMetadata) // GET /notes - get all notes (metadata + titles)
/*
Clients should utilize `rs.ListMetadata` to load index of user's available notes (e.g.
sidebar view), use `rs.GetFullNote` to get full notes individually, and if request the
versioning history with `rs.GetVersionHistory` if necessary (and similarly fetch each
version individually if the client wants to view them).
*/
r.Route(fmt.Sprintf("/{%s}", noteUUIDCtxParameter), func(r chi.Router) {
r.Use(uuidCtx(noteUUIDCtxParameter))
r.Use(noteCtx(rs.Notes)) // DB -> req. context (metadata + active version)
r.Get("/", rs.GetFullNote) // GET /notes/{id} - get note from context
r.Delete("/", rs.Delete) // DELETE /notes/{id} - delete note
r.Get("/versions", rs.GetVersionHistory) // GET /notes/{id}/versions - get full versioning history
r.Post("/versions", rs.CreateVersion) // POST /notes/{id}/versions - create new version
r.Route(fmt.Sprintf("/{%s}", versionUUIDCtxParameter), func(r chi.Router) {
r.Use(uuidCtx(versionUUIDCtxParameter))
r.Use(versionCtx(rs.Notes)) // DB -> req. context (scoped version)
r.Get("/", rs.GetFullVersion) // GET /notes/{id}/{id} - get
})
})
})
return r
}
// Handler for new note creation. Creates the parent metadata object (`notes` table) and an initial
// placeholder content version (`note_versions` table), and returns the placeholder contents to the
// caller in the HTTP response.
func (rs *notesResource) Create(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(userCtxKey{}).(*userClaims)
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
userID, err := uuid.Parse(user.Subject)
if err != nil {
respondError(w, http.StatusUnauthorized, "Invalid user ID")
return
}
// Metadata object (parent)
note, err := rs.Notes.CreateNote(r.Context(), userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create note")
return
}
// Initial (empty) placeholder version of the contents
err = rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{
NoteID: note.ID,
Title: initVersionTitle,
Content: initVersionContent,
ContentHash: sha1ContentHash(initVersionTitle, initVersionContent),
})
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create initial version")
return
}
// Placeholder contents are decided server-side, so we need to inform the client of them via a
// one-time-use DTO
type response struct {
Title string `json:"title"`
Content string `json:"content"`
}
res := response{
Title: initVersionTitle,
Content: initVersionContent,
}
respondJSON(w, http.StatusCreated, res)
}
func (rs *notesResource) ListMetadata(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(userCtxKey{}).(*userClaims)
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
userID, err := uuid.Parse(user.Subject)
if err != nil {
respondError(w, http.StatusUnauthorized, "Invalid user ID")
return
}
limit, offset := getPaginationParams(r)
notes, err := rs.Notes.ListNotes(r.Context(), data.ListNotesParams{
UserID: userID,
Limit: limit,
Offset: offset,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to retrieve notes")
return
}
for _, note := range notes {
if userID != note.OwnerID {
respondError(w, http.StatusForbidden, "Forbidden")
return
}
}
respondJSON(w, http.StatusOK, notes)
}
// Handler for returning the currently scoped (included to the request's context by a middleware)
// full note object.
func (rs *notesResource) GetFullNote(w http.ResponseWriter, r *http.Request) {
fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow)
if !ok {
respondError(w, http.StatusNotFound, "Note not found")
return
}
respondJSON(w, http.StatusOK, fullNote)
}
// Handler for hard deelting the currently scoped note (including its versions via database cascade).
func (rs *notesResource) Delete(w http.ResponseWriter, r *http.Request) {
fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow)
if !ok {
respondError(w, http.StatusNotFound, "Note not found")
return
}
user, ok := r.Context().Value(userCtxKey{}).(*userClaims)
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
userID, err := uuid.Parse(user.Subject)
if err != nil {
respondError(w, http.StatusUnauthorized, "Invalid user ID")
return
}
err = rs.Notes.DeleteNote(r.Context(), data.DeleteNoteParams{
ID: fullNote.NoteID,
UserID: userID, // NOTE: using `fullNote.userID` here'd be insecure
})
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to delete note")
return
}
w.WriteHeader(http.StatusNoContent)
}
// Handler for listing the currently scoped note's version history. If pagination parameters
// (`limit` and `offset`) aren't defined, limit of 50 versions (with offset 0) will be returned.
func (rs *notesResource) GetVersionHistory(w http.ResponseWriter, r *http.Request) {
fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow)
if !ok {
respondError(w, http.StatusNotFound, "Note not found")
return
}
limit, offset := getPaginationParams(r)
versions, err := rs.Notes.GetVersionHistory(r.Context(), data.GetVersionHistoryParams{
NoteID: fullNote.NoteID,
Limit: limit,
Offset: offset,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get version history")
return
}
respondJSON(w, http.StatusOK, versions)
}
// Handler for creating a new content version for the currently scoped note. Will check the incoming
// JSON object's integrity and perform a de-duplication check for identical versions stored in the
// database (SHA-1 hash of version contents). If a duplicate version is found, it'll be placed as the
// active version by swapping its version number to HEAD+1.
func (rs *notesResource) CreateVersion(w http.ResponseWriter, r *http.Request) {
fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow)
if !ok {
respondError(w, http.StatusNotFound, "Note not found")
return
}
var req struct {
Title *string `json:"title"`
Content *string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Title == nil || req.Content == nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Extra check for frontend readability reasons (max. length isn't specifically limited in the database)
if len(*req.Title) > titleMaxLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Title must be shorter than %d characters", titleMaxLength))
return
}
/*
The SQL query handles de-duplication checks and "intelligent" versioning increments, so we
don't have to worry about them here (`latest_version` = highest version number that exists
in this note's context; `current_version` = note's active content version):
- New version's contents are a duplicate of a historical version:
- Don't increment `latest_version`
- Sync `current_version` with the `version_number` of the duplicate version
- New version's contents are unique:
- Increment `latest_version`
- Sync `current_version` with `latest_version`
*/
err := rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{
NoteID: fullNote.NoteID,
Title: *req.Title,
Content: *req.Content,
ContentHash: sha1ContentHash(*req.Title, *req.Content),
})
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create note version")
return
}
w.WriteHeader(http.StatusNoContent)
}
// Handler for returning full data of the currently scoped note version. Identical to the beginning
// of the `RollbackNoteVersion` handler.
func (rs *notesResource) GetFullVersion(w http.ResponseWriter, r *http.Request) {
fullVersion, ok := r.Context().Value(noteCtxKey{}).(*data.GetVersionRow)
if !ok {
respondError(w, http.StatusNotFound, "Note not found")
return
}
respondJSON(w, http.StatusOK, fullVersion)
}
// Parse `limit` and `offset` 32-bit integer URL parameters from the given request. Defaults to
// limit of 50 and offset 0 if parameters are missing/invalid.
func getPaginationParams(r *http.Request) (limit int32, offset int32) {
defaultLimit := 50
defaultOffset := 0
limitStr := r.URL.Query().Get("limit")
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
defaultLimit = l
}
}
offsetStr := r.URL.Query().Get("offset")
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
defaultOffset = o
}
}
return int32(defaultLimit), int32(defaultOffset)
}
// Concatenate the title and content strings, calculate a SHA-1 hash of the resulting string, and
// return the resulting hash as a string.
func sha1ContentHash(title, content string) string {
hashContent := title + content
hash := sha1.Sum([]byte(hashContent))
hashStr := strings.ToUpper(hex.EncodeToString(hash[:]))
return hashStr
}