- 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
332 lines
11 KiB
Go
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
|
|
}
|