package api import ( "bytes" "fmt" "io" "mime" "net/http" "path/filepath" "strings" "time" "gardomatic.kleiax.de/internal/platform/validate" "gardomatic.kleiax.de/internal/storage" ) type journalEntryInput struct { Title *string `json:"title"` Body *string `json:"body"` CreatedAt *time.Time `json:"created_at"` Tags []string `json:"tags"` EntryType *storage.JournalEntryType `json:"entry_type"` } func (input journalEntryInput) apply(entry *storage.JournalEntry) { if input.Title != nil { entry.Title = strings.TrimSpace(*input.Title) } if input.Body != nil { entry.Body = strings.TrimSpace(*input.Body) } if input.CreatedAt != nil { entry.CreatedAt = input.CreatedAt.UTC() } if input.EntryType != nil { entry.EntryType = *input.EntryType } } func (app *application) createJournalEntryHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) user, _ := app.contextGetAuthenticatedUser(r) var input journalEntryInput if err := app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } entry := storage.JournalEntry{GardenID: gardenID, AuthorID: user.ID, AuthorName: user.Name, EntryType: storage.JournalEntryTypeJournal} entry.CreatedAt = time.Now().UTC() input.apply(&entry) entry.Tags = storage.NormalizeTags(input.Tags) if !app.validateJournalEntry(w, r, entry) { return } var err error entry, err = app.models.Journal.Insert(entry) if err != nil { app.respondToEntityModelError(w, r, err) return } entry.Tags, err = app.saveTags(gardenID, storage.TagEntityJournal, entry.ID, entry.Tags) if err != nil { app.serverErrorResponse(w, r, err) return } headers := make(http.Header) headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/journal/%d", gardenID, entry.ID)) if err = app.writeJSON(w, http.StatusCreated, envelope{"journal_entry": entry}, headers); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) listJournalEntriesHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) entryType := storage.JournalEntryType(r.URL.Query().Get("type")) if entryType == "" { entryType = storage.JournalEntryTypeJournal } if entryType != storage.JournalEntryTypeJournal && entryType != storage.JournalEntryTypePinboard { app.badRequestResponse(w, r, fmt.Errorf("type must be journal or pinboard")) return } entries, err := app.models.Journal.GetAllForGarden(gardenID, entryType) if err != nil { app.serverErrorResponse(w, r, err) return } for i := range entries { entries[i].Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, entries[i].ID) if err != nil { app.serverErrorResponse(w, r, err) return } } if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entries": entries}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) showJournalEntryHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) id, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } entry, err := app.models.Journal.Get(gardenID, id) if err != nil { app.respondToEntityModelError(w, r, err) return } entry.Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, entry.ID) if err != nil { app.serverErrorResponse(w, r, err) return } if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entry": entry}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) updateJournalEntryHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) id, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } entry, err := app.models.Journal.Get(gardenID, id) if err != nil { app.respondToEntityModelError(w, r, err) return } var input journalEntryInput if err = app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } input.apply(&entry) if input.Tags != nil { entry.Tags = storage.NormalizeTags(input.Tags) } else { entry.Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, id) } if err != nil { app.serverErrorResponse(w, r, err) return } if !app.validateJournalEntry(w, r, entry) { return } entry, err = app.models.Journal.Update(gardenID, entry) if err != nil { app.respondToEntityModelError(w, r, err) return } if input.Tags != nil { entry.Tags, err = app.saveTags(gardenID, storage.TagEntityJournal, id, entry.Tags) } if err != nil { app.serverErrorResponse(w, r, err) return } if stored, getErr := app.models.Journal.Get(gardenID, id); getErr == nil { entry.Attachments = stored.Attachments } if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entry": entry}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) deleteJournalEntryHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) id, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } if err = app.models.Journal.Delete(gardenID, id); err != nil { app.respondToEntityModelError(w, r, err) return } w.WriteHeader(http.StatusNoContent) } func (app *application) createJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) entryID, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } r.Body = http.MaxBytesReader(w, r.Body, storage.MaxJournalAttachmentSize+(1<<20)) file, header, err := r.FormFile("file") if err != nil { app.badRequestResponse(w, r, fmt.Errorf("file must be provided")) return } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, storage.MaxJournalAttachmentSize+1)) if err != nil { app.badRequestResponse(w, r, err) return } if len(data) > storage.MaxJournalAttachmentSize { app.badRequestResponse(w, r, fmt.Errorf("file must not be larger than 25 MB")) return } mediaType := strings.ToLower(strings.TrimSpace(strings.Split(header.Header.Get("Content-Type"), ";")[0])) detected := http.DetectContentType(data) if mediaType == "" || mediaType == "application/octet-stream" { mediaType = detected } if !allowedJournalMediaType(mediaType) { app.badRequestResponse(w, r, fmt.Errorf("only photos, videos and audio files are supported")) return } name := filepath.Base(strings.ReplaceAll(header.Filename, "\\", "/")) if name == "." || name == "" { name = "attachment" } if len(name) > 255 { name = name[:255] } attachment, err := app.models.Journal.InsertAttachment(gardenID, entryID, storage.JournalAttachment{FileName: name, MediaType: mediaType, Data: data}) if err != nil { app.respondToEntityModelError(w, r, err) return } if err = app.writeJSON(w, http.StatusCreated, envelope{"attachment": attachment}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) createJournalLibraryAttachmentHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) entryID, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } var input struct { ImageID int `json:"image_id"` } if err = app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } if input.ImageID < 1 { app.badRequestResponse(w, r, fmt.Errorf("image_id must be provided")) return } image, err := app.models.Images.Get(gardenID, input.ImageID) if err != nil { app.respondToEntityModelError(w, r, err) return } name := image.FileName if name == "" { name = "Bild" } attachment, err := app.models.Journal.InsertAttachment(gardenID, entryID, storage.JournalAttachment{FileName: name, MediaType: image.MediaType, Size: image.Size, ImageID: &image.ID}) if err != nil { app.respondToEntityModelError(w, r, err) return } if err = app.writeJSON(w, http.StatusCreated, envelope{"attachment": attachment}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func allowedJournalMediaType(value string) bool { switch value { case "image/jpeg", "image/png", "image/webp", "image/gif", "video/mp4", "video/webm", "video/quicktime", "audio/mpeg", "audio/mp4", "audio/ogg", "audio/webm", "audio/wav", "audio/x-wav": return true default: return false } } func (app *application) showJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) entryID, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } attachmentID, err := app.readNamedIDParam(r, "attachmentID") if err != nil { app.notFoundResponse(w, r) return } a, err := app.models.Journal.GetAttachment(gardenID, entryID, attachmentID) if err != nil { app.respondToEntityModelError(w, r, err) return } w.Header().Set("Content-Type", a.MediaType) w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": a.FileName})) w.Header().Set("Cache-Control", "private, max-age=3600") http.ServeContent(w, r, a.FileName, a.CreatedAt, bytes.NewReader(a.Data)) } func (app *application) deleteJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) entryID, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } attachmentID, err := app.readNamedIDParam(r, "attachmentID") if err != nil { app.notFoundResponse(w, r) return } if err = app.models.Journal.DeleteAttachment(gardenID, entryID, attachmentID); err != nil { app.respondToEntityModelError(w, r, err) return } w.WriteHeader(http.StatusNoContent) } func (app *application) validateJournalEntry(w http.ResponseWriter, r *http.Request, entry storage.JournalEntry) bool { v := validate.New() storage.ValidateJournalEntry(v, entry) storage.ValidateTags(v, entry.Tags) if !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return false } return true }