package handlers import ( "encoding/json" "errors" "log" "net/http" "strconv" "strings" "time" "unicode/utf8" "blackbox/server/internal/auth" "blackbox/server/internal/models" "blackbox/shared/types" "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gorm.io/gorm" ) const ( maxNoteLength = 2000 defaultNotesPage = 50 maxNotesPage = 100 maxNoteBodyBytes = 16 << 10 ) func CreateNote(database *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims, ok := auth.ClaimsFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } entryID := chi.URLParam(r, "id") var entry types.Entry if err := database.First(&entry, "id = ?", entryID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { writeError(w, http.StatusNotFound, "entry not found") return } writeError(w, http.StatusInternalServerError, "failed to fetch entry") return } var body struct { Content string `json:"content"` } if !decodeJSONBody(w, r, maxNoteBodyBytes, &body) { return } content := strings.TrimSpace(body.Content) if content == "" { writeError(w, http.StatusBadRequest, "content is required") return } if utf8.RuneCountInString(content) > maxNoteLength { writeError(w, http.StatusBadRequest, "content exceeds max length") return } note := models.EntryNote{ ID: ulid.Make().String(), EntryID: entryID, UserID: claims.UserID, Username: claims.Username, Content: content, CreatedAt: time.Now().UTC(), } if err := database.Create(¬e).Error; err != nil { writeError(w, http.StatusInternalServerError, "failed to save note") return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(note); err != nil { log.Printf("CreateNote encode: %v", err) } } } func ListNotes(database *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { entryID := chi.URLParam(r, "id") limit := defaultNotesPage if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" { if parsedLimit, err := strconv.Atoi(rawLimit); err == nil && parsedLimit > 0 { if parsedLimit > maxNotesPage { limit = maxNotesPage } else { limit = parsedLimit } } } offset := 0 if rawOffset := r.URL.Query().Get("offset"); rawOffset != "" { if parsedOffset, err := strconv.Atoi(rawOffset); err == nil && parsedOffset >= 0 { offset = parsedOffset } } var notes []models.EntryNote if err := database. Where("entry_id = ?", entryID). Order("created_at ASC"). Order("id ASC"). Offset(offset). Limit(limit + 1). Find(¬es).Error; err != nil { writeError(w, http.StatusInternalServerError, "failed to fetch notes") return } if notes == nil { notes = []models.EntryNote{} } hasMore := len(notes) > limit if hasMore { notes = notes[:limit] } response := struct { Notes []models.EntryNote `json:"notes"` HasMore bool `json:"has_more"` NextOffset int `json:"next_offset,omitempty"` }{ Notes: notes, HasMore: hasMore, } if hasMore { response.NextOffset = offset + len(notes) } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { log.Printf("ListNotes encode: %v", err) } } } func DeleteNote(database *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims, ok := auth.ClaimsFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } noteID := chi.URLParam(r, "id") result := database.Where("id = ? AND user_id = ?", noteID, claims.UserID).Delete(&models.EntryNote{}) if result.Error != nil { writeError(w, http.StatusInternalServerError, "failed to delete note") return } if result.RowsAffected == 0 { var note models.EntryNote if err := database.First(¬e, "id = ?", noteID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { writeError(w, http.StatusNotFound, "note not found") return } writeError(w, http.StatusInternalServerError, "failed to fetch note") return } writeError(w, http.StatusForbidden, "not your note") return } w.WriteHeader(http.StatusNoContent) } }