package web import ( "bytes" "errors" "fmt" "io" "mime/multipart" "net/http" "strconv" "strings" "time" "gardomatic.kleiax.de/lib/client" ) const maxJournalFormSize = 100 << 20 type journalForm struct { Title string Body string DateTime string Keywords string TagSuggestions []string Attachments []client.JournalAttachment LibraryImageIDs []int Errors map[string]string } type gardenEntryView struct { entryType string path string } var ( journalEntryView = gardenEntryView{entryType: client.JournalEntryTypeJournal, path: "journal"} pinboardEntryView = gardenEntryView{entryType: client.JournalEntryTypePinboard, path: "pinboard"} ) func (app *application) journal(w http.ResponseWriter, r *http.Request) { app.gardenEntries(w, r, journalEntryView) } func (app *application) pinboard(w http.ResponseWriter, r *http.Request) { app.gardenEntries(w, r, pinboardEntryView) } func (app *application) gardenEntries(w http.ResponseWriter, r *http.Request, view gardenEntryView) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } apiClient := client.FromContext(r.Context()) garden, _, err := apiClient.Garden(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } entries, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, view.entryType) if err != nil { app.handleAPIError(w, r, err) return } data := app.newTemplateData(r) entries, data.Pagination = paginateCollection(r, entries) data.Garden, data.JournalEntries, data.ContentKind = &garden, entries, view.entryType app.render(w, http.StatusOK, "journal.tmpl", data) } func (app *application) journalCreate(w http.ResponseWriter, r *http.Request) { app.gardenEntryCreate(w, r, journalEntryView) } func (app *application) pinboardCreate(w http.ResponseWriter, r *http.Request) { app.gardenEntryCreate(w, r, pinboardEntryView) } func (app *application) gardenEntryCreate(w http.ResponseWriter, r *http.Request, view gardenEntryView) { form := journalForm{DateTime: time.Now().Format("2006-01-02T15:04"), Errors: map[string]string{}} app.loadJournalTagSuggestions(r, &form) app.renderJournalForm(w, r, form, http.StatusOK, 0, view) } func (app *application) journalEdit(w http.ResponseWriter, r *http.Request) { app.gardenEntryEdit(w, r, journalEntryView) } func (app *application) pinboardEdit(w http.ResponseWriter, r *http.Request) { app.gardenEntryEdit(w, r, pinboardEntryView) } func (app *application) gardenEntryEdit(w http.ResponseWriter, r *http.Request, view gardenEntryView) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } entryID, err := app.readPathID(r, "entryID") if err != nil { app.notFound(w) return } entry, _, err := client.FromContext(r.Context()).JournalEntry(r.Context(), gardenID, entryID) if err != nil { app.handleAPIError(w, r, err) return } if entry.EntryType != view.entryType { app.notFound(w) return } form := journalForm{Title: entry.Title, Body: entry.Body, DateTime: entry.CreatedAt.In(time.Local).Format("2006-01-02T15:04"), Keywords: strings.Join(entry.Tags, ", "), Attachments: entry.Attachments, Errors: map[string]string{}} app.loadJournalTagSuggestions(r, &form) app.renderJournalForm(w, r, form, http.StatusOK, entryID, view) } func (app *application) journalSave(w http.ResponseWriter, r *http.Request) { app.gardenEntrySave(w, r, journalEntryView) } func (app *application) pinboardSave(w http.ResponseWriter, r *http.Request) { app.gardenEntrySave(w, r, pinboardEntryView) } func (app *application) gardenEntrySave(w http.ResponseWriter, r *http.Request, view gardenEntryView) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } entryID := 0 if id, readErr := app.readPathID(r, "entryID"); readErr == nil { entryID = id } r.Body = http.MaxBytesReader(w, r.Body, maxJournalFormSize) if err = r.ParseMultipartForm(16 << 20); err != nil { app.clientError(w, http.StatusRequestEntityTooLarge) return } form := journalForm{Title: strings.TrimSpace(r.FormValue("title")), Body: strings.TrimSpace(r.FormValue("body")), DateTime: r.FormValue("date_time"), Keywords: r.FormValue("keywords"), Errors: map[string]string{}} for _, value := range r.MultipartForm.Value["library_image_id"] { if id, parseErr := strconv.Atoi(value); parseErr == nil && id > 0 { form.LibraryImageIDs = append(form.LibraryImageIDs, id) } } if form.DateTime == "" { form.DateTime = time.Now().Format("2006-01-02T15:04") } app.loadJournalTagSuggestions(r, &form) if form.Title == "" && view.entryType == client.JournalEntryTypeJournal { form.Errors["title"] = "Ein Titel ist erforderlich." } if len(form.Body) > 100_000 { form.Errors["body"] = "Der Text darf höchstens 100.000 Zeichen enthalten." } createdAt, parseErr := time.ParseInLocation("2006-01-02T15:04", form.DateTime, time.Local) if parseErr != nil { form.Errors["date_time"] = "Bitte ein gültiges Datum und eine gültige Uhrzeit angeben." } apiClient := client.FromContext(r.Context()) if entryID > 0 { if existing, _, getErr := apiClient.JournalEntry(r.Context(), gardenID, entryID); getErr == nil { if existing.EntryType != view.entryType { app.notFound(w) return } form.Attachments = existing.Attachments } else { app.handleAPIError(w, r, getErr) return } } if len(form.Errors) > 0 { app.renderJournalForm(w, r, form, http.StatusUnprocessableEntity, entryID, view) return } entryType := view.entryType input := client.JournalEntryInput{Title: &form.Title, Body: &form.Body, CreatedAt: &createdAt, Tags: parseKeywords(form.Keywords), EntryType: &entryType} var entry client.JournalEntry if entryID > 0 { entry, _, err = apiClient.UpdateJournalEntry(r.Context(), gardenID, entryID, input) } else { entry, _, err = apiClient.CreateJournalEntry(r.Context(), gardenID, input) } if err != nil { var apiError *client.APIError if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity { form.Errors = apiError.Validation app.renderJournalForm(w, r, form, http.StatusUnprocessableEntity, entryID, view) return } app.handleAPIError(w, r, err) return } entryID = entry.ID for _, value := range r.MultipartForm.Value["remove_attachment"] { if id, parseErr := strconv.Atoi(value); parseErr == nil { _, _ = apiClient.DeleteJournalAttachment(r.Context(), gardenID, entryID, id) } } body := form.Body for index, header := range r.MultipartForm.File["embedded_images"] { attachment, uploadErr := app.uploadJournalFile(r, apiClient, gardenID, entryID, header) if uploadErr != nil { app.handleAPIError(w, r, uploadErr) return } url := webPath(view.path+".attachment", gardenID, entryID, attachment.ID) body = strings.ReplaceAll(body, fmt.Sprintf("/pending-journal-image/%d", index), url) } for _, header := range r.MultipartForm.File["attachments"] { if _, uploadErr := app.uploadJournalFile(r, apiClient, gardenID, entryID, header); uploadErr != nil { app.handleAPIError(w, r, uploadErr) return } } for _, imageID := range form.LibraryImageIDs { if _, _, attachErr := apiClient.AttachJournalLibraryImage(r.Context(), gardenID, entryID, imageID); attachErr != nil { app.handleAPIError(w, r, attachErr) return } } if body != form.Body { _, _, err = apiClient.UpdateJournalEntry(r.Context(), gardenID, entryID, client.JournalEntryInput{Body: &body}) } if err != nil { app.handleAPIError(w, r, err) return } http.Redirect(w, r, webPath("garden."+view.path, gardenID), http.StatusSeeOther) } func (app *application) uploadJournalFile(r *http.Request, apiClient *client.Client, gardenID, entryID int, header *multipart.FileHeader) (client.JournalAttachment, error) { file, err := header.Open() if err != nil { return client.JournalAttachment{}, err } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, (25<<20)+1)) if err != nil { return client.JournalAttachment{}, err } if len(data) > 25<<20 { return client.JournalAttachment{}, fmt.Errorf("anhang %q ist größer als 25 MB", header.Filename) } mediaType := strings.Split(header.Header.Get("Content-Type"), ";")[0] attachment, _, err := apiClient.UploadJournalAttachment(r.Context(), gardenID, entryID, header.Filename, mediaType, data) return attachment, err } func (app *application) journalDelete(w http.ResponseWriter, r *http.Request) { app.gardenEntryDelete(w, r, journalEntryView) } func (app *application) pinboardDelete(w http.ResponseWriter, r *http.Request) { app.gardenEntryDelete(w, r, pinboardEntryView) } func (app *application) gardenEntryDelete(w http.ResponseWriter, r *http.Request, view gardenEntryView) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } entryID, err := app.readPathID(r, "entryID") if err != nil { app.notFound(w) return } apiClient := client.FromContext(r.Context()) entry, _, err := apiClient.JournalEntry(r.Context(), gardenID, entryID) if err != nil { app.handleAPIError(w, r, err) return } if entry.EntryType != view.entryType { app.notFound(w) return } if _, err = apiClient.DeleteJournalEntry(r.Context(), gardenID, entryID); err != nil { app.handleAPIError(w, r, err) return } http.Redirect(w, r, webPath("garden."+view.path, gardenID), http.StatusSeeOther) } func (app *application) journalAttachment(w http.ResponseWriter, r *http.Request) { app.gardenEntryAttachment(w, r) } func (app *application) pinboardAttachment(w http.ResponseWriter, r *http.Request) { app.gardenEntryAttachment(w, r) } func (app *application) gardenEntryAttachment(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } entryID, err := app.readPathID(r, "entryID") if err != nil { app.notFound(w) return } attachmentID, err := app.readPathID(r, "attachmentID") if err != nil { app.notFound(w) return } attachment, _, err := client.FromContext(r.Context()).JournalAttachment(r.Context(), gardenID, entryID, attachmentID) if err != nil { app.handleAPIError(w, r, err) return } w.Header().Set("Content-Type", attachment.MediaType) w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", strings.ReplaceAll(attachment.FileName, "\"", ""))) w.Header().Set("Cache-Control", "private, max-age=3600") http.ServeContent(w, r, attachment.FileName, attachment.CreatedAt, bytes.NewReader(attachment.Data)) } func (app *application) renderJournalForm(w http.ResponseWriter, r *http.Request, form journalForm, status, entryID int, view gardenEntryView) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } garden, _, err := client.FromContext(r.Context()).Garden(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } data := app.newTemplateData(r) data.Garden, data.Form, data.EntryID, data.UseJournalEditor, data.ContentKind = &garden, form, entryID, true, view.entryType data.Images, err = app.loadImageLibrary(r, gardenID) if err != nil { app.handleAPIError(w, r, err) return } app.render(w, status, "journal_form.tmpl", data) } func (app *application) loadJournalTagSuggestions(r *http.Request, form *journalForm) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { return } if tags, _, tagErr := client.FromContext(r.Context()).GardenTags(r.Context(), gardenID); tagErr == nil { form.TagSuggestions = tags } }