From 47f898561c1a69d431a1cae6dfb6466660561e91 Mon Sep 17 00:00:00 2001 From: Noah Date: Thu, 20 Oct 2022 21:02:30 -0700 Subject: [PATCH] Forum Photo Attachments * Add support to upload a picture to forum posts and replies, in forums that have the PermitPhotos setting enabled. * New DB table: CommentPhoto holds the association between a photo and a forum ID. Photos can be uploaded at preview time (before a CommentID is available) and get associated to the CommentID on save. * Cron endpoint /v1/comment-photos/remove-orphaned can clean up orphaned photos without a CommentID older than 24 hours. * Add "Photo Boards" as a default forum category for new boards. --- README.md | 22 +++ pkg/config/enum.go | 1 + pkg/config/variable.go | 3 + pkg/controller/api/orphaned_comment_photos.go | 93 ++++++++++++ pkg/controller/forum/new_post.go | 130 ++++++++++++++++- pkg/controller/forum/thread.go | 7 + pkg/models/comment_photo.go | 112 +++++++++++++++ pkg/models/models.go | 1 + pkg/router/router.go | 1 + web/templates/account/dashboard.html | 1 - web/templates/forum/index.html | 7 + web/templates/forum/new_post.html | 136 +++++++++++++++++- web/templates/forum/thread.html | 114 ++++++++++++++- web/templates/photo/gallery.html | 5 +- web/templates/photo/upload.html | 3 +- 15 files changed, 624 insertions(+), 12 deletions(-) create mode 100644 pkg/controller/api/orphaned_comment_photos.go create mode 100644 pkg/models/comment_photo.go diff --git a/README.md b/README.md index b6ef11f..9237816 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,28 @@ the web app by using the admin controls on their profile page. templates, issue redirects, error pages, ... * `pkg/utility`: miscellaneous useful functions for the app. +## Cron API Endpoints + +In settings.json get or configure the CronAPIKey (a UUID4 value is good and +the app generates a fresh one by default). The following are the cron API +endpoints that you may want to configure to run periodic maintenance tasks +on the app, such as to remove orphaned comment photos. + +### GET /v1/comment-photos/remove-orphaned + +Query parameters: `apiKey` which is the CronAPIKey. + +This endpoint removes orphaned CommentPhotos (photo attachments to forum +posts). An orphaned photo is one that has no CommentID and was uploaded +older than 24 hours ago; e.g. a user uploaded a picture but then did not +complete the posting of their comment. + +Suggested crontab: + +```cron +0 2 * * * curl "http://localhost:8080/v1/comment-photos/remove-orphaned?apiKey=X" +``` + ## License GPLv3. \ No newline at end of file diff --git a/pkg/config/enum.go b/pkg/config/enum.go index 2a01ff9..dc497e2 100644 --- a/pkg/config/enum.go +++ b/pkg/config/enum.go @@ -92,6 +92,7 @@ var ( "Rules and Announcements", "Nudists", "Exhibitionists", + "Photo Boards", "Anything Goes", } ) diff --git a/pkg/config/variable.go b/pkg/config/variable.go index fcb55e5..5ba032e 100644 --- a/pkg/config/variable.go +++ b/pkg/config/variable.go @@ -8,6 +8,7 @@ import ( "os" "code.nonshy.com/nonshy/website/pkg/log" + "github.com/google/uuid" ) // Current loaded settings.json @@ -17,6 +18,7 @@ var Current = DefaultVariable() type Variable struct { BaseURL string AdminEmail string + CronAPIKey string Mail Mail Redis Redis Database Database @@ -41,6 +43,7 @@ func DefaultVariable() Variable { SQLite: "database.sqlite", Postgres: "host=localhost user=nonshy password=nonshy dbname=nonshy port=5679 sslmode=disable TimeZone=America/Los_Angeles", }, + CronAPIKey: uuid.New().String(), } } diff --git a/pkg/controller/api/orphaned_comment_photos.go b/pkg/controller/api/orphaned_comment_photos.go new file mode 100644 index 0000000..c52fbb3 --- /dev/null +++ b/pkg/controller/api/orphaned_comment_photos.go @@ -0,0 +1,93 @@ +package api + +import ( + "fmt" + "net/http" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/photo" +) + +// RemoveOrphanedCommentPhotos API. +// +// URL: /v1/comment-photos/remove-orphaned +// +// Query parameters: ?apiKey={CronAPIKey} +// +// This endpoint looks for CommentPhotos having a blank CommentID that were created older +// than 24 hours ago and removes them. Configure the "CronAPIKey" in your settings.json +// and pass it as the query parameter. +func RemoveOrphanedCommentPhotos() http.HandlerFunc { + // Response JSON schema. + type Response struct { + OK bool `json:"OK"` + Error string `json:"error,omitempty"` + Total int64 `json:"total"` + Removed int `json:"removed"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + SendJSON(w, http.StatusNotAcceptable, Response{ + Error: "GET method only", + }) + return + } + + // Get and validate the API key. + var ( + apiKey = r.FormValue("apiKey") + compare = config.Current.CronAPIKey + ) + + if compare == "" { + SendJSON(w, http.StatusInternalServerError, Response{ + OK: false, + Error: "app CronAPIKey is not configured", + }) + return + } else if apiKey == "" || apiKey != compare { + SendJSON(w, http.StatusInternalServerError, Response{ + OK: false, + Error: "invalid apiKey query parameter", + }) + return + } + + // Do the needful. + photos, total, err := models.GetOrphanedCommentPhotos() + if err != nil { + SendJSON(w, http.StatusInternalServerError, Response{ + OK: false, + Error: fmt.Sprintf("GetOrphanedCommentPhotos: %s", err), + }) + return + } + + for _, row := range photos { + if err := photo.Delete(row.Filename); err != nil { + SendJSON(w, http.StatusInternalServerError, Response{ + OK: false, + Error: fmt.Sprintf("Photo ID %d: removing file %s: %s", row.ID, row.Filename, err), + }) + return + } + + if err := row.Delete(); err != nil { + SendJSON(w, http.StatusInternalServerError, Response{ + OK: false, + Error: fmt.Sprintf("DeleteOrphanedCommentPhotos(%d): %s", row.ID, err), + }) + return + } + } + + // Send success response. + SendJSON(w, http.StatusOK, Response{ + OK: true, + Total: total, + Removed: len(photos), + }) + }) +} diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go index d34cbfd..bc23c33 100644 --- a/pkg/controller/forum/new_post.go +++ b/pkg/controller/forum/new_post.go @@ -1,13 +1,18 @@ package forum import ( + "bytes" "fmt" + "io" "net/http" + "os" + "path/filepath" "strconv" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/markdown" "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/photo" "code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/templates" ) @@ -23,6 +28,8 @@ func NewPost() http.HandlerFunc { quoteCommentID = r.FormValue("quote") // add reply to thread while quoting a comment editCommentID = r.FormValue("edit") // edit your comment intent = r.FormValue("intent") // preview or submit + photoIntent = r.FormValue("photo_intent") // upload, remove photo attachment + photoID = r.FormValue("photo_id") // existing CommentPhoto ID title = r.FormValue("title") // for new forum post only message = r.PostFormValue("message") // comment body isPinned = r.PostFormValue("pinned") == "true" // owners or admins only @@ -37,6 +44,9 @@ func NewPost() http.HandlerFunc { // thread, we show and accept the thread settings to be updated as // well (pinned, explicit, noreply) isOriginalComment bool + + // Attached photo object. + commentPhoto *models.CommentPhoto ) // Get the current user. @@ -69,6 +79,19 @@ func NewPost() http.HandlerFunc { } } + // Does the comment have an existing Photo ID? + if len(photoID) > 0 { + if i, err := strconv.Atoi(photoID); err == nil { + if found, err := models.GetCommentPhoto(uint64(i)); err != nil { + session.FlashError(w, r, "Couldn't find comment photo ID #%d!", i) + templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment)) + return + } else { + commentPhoto = found + } + } + } + // Are we pre-filling the message with a quotation of an existing comment? if len(quoteCommentID) > 0 { if i, err := strconv.Atoi(quoteCommentID); err == nil { @@ -95,6 +118,11 @@ func NewPost() http.HandlerFunc { message = comment.Message } + // Did this comment have a picture? Load it if so. + if photos, err := comment.GetPhotos(); err == nil && len(photos) > 0 { + commentPhoto = photos[0] + } + // Is this the OG thread of the post? if thread.CommentID == comment.ID { isOriginalComment = true @@ -130,8 +158,89 @@ func NewPost() http.HandlerFunc { // Submitting the form. if r.Method == http.MethodPost { + // Is a photo coming along? + if forum.PermitPhotos { + // Removing or replacing? + if photoIntent == "remove" || photoIntent == "replace" { + // Remove the attached photo. + if commentPhoto == nil { + session.FlashError(w, r, "Couldn't remove photo from post: no photo found!") + } else { + photo.Delete(commentPhoto.Filename) + if err := commentPhoto.Delete(); err != nil { + session.FlashError(w, r, "Couldn't remove photo from DB: %s", err) + } else { + session.Flash(w, r, "Photo attachment %sd from this post.", photoIntent) + commentPhoto = nil + } + } + } + + // Uploading a new picture? + if photoIntent == "upload" || photoIntent == "replace" { + log.Info("Receiving a photo upload for forum post") + + // Get their file upload. + file, header, err := r.FormFile("file") + if err != nil { + session.FlashError(w, r, "Error receiving your file: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Read the file contents. + log.Debug("Receiving uploaded file (%d bytes): %s", header.Size, header.Filename) + var buf bytes.Buffer + io.Copy(&buf, file) + + filename, _, err := photo.UploadPhoto(photo.UploadConfig{ + Extension: filepath.Ext(header.Filename), + Data: buf.Bytes(), + }) + if err != nil { + session.FlashError(w, r, "Error in UploadPhoto: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Create the PhotoComment. If we don't have a Comment ID yet, let it be empty. + ptmpl := models.CommentPhoto{ + Filename: filename, + } + if comment != nil { + ptmpl.CommentID = comment.ID + } + + // Get the filesize. + if stat, err := os.Stat(photo.DiskPath(filename)); err == nil { + ptmpl.Filesize = stat.Size() + } + + // Create it in DB! + p, err := models.CreateCommentPhoto(ptmpl) + if err != nil { + session.FlashError(w, r, "Couldn't create CommentPhoto in DB: %s", err) + } else { + log.Info("New photo! %+v", p) + } + + commentPhoto = p + } + } + // Default intent is preview unless told to submit. if intent == "submit" { + // A message OR a photo is required. + if forum.PermitPhotos && message == "" && commentPhoto == nil { + session.FlashError(w, r, "A message OR photo is required for this post.") + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) + return + } else if !forum.PermitPhotos && message == "" { + session.FlashError(w, r, "A message is required for this post.") + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) + return + } + // Are we modifying an existing comment? if comment != nil { comment.Message = message @@ -157,11 +266,19 @@ func NewPost() http.HandlerFunc { // Are we replying to an existing thread? if thread != nil { - if _, err := thread.Reply(currentUser, message); err != nil { + if reply, err := thread.Reply(currentUser, message); err != nil { session.FlashError(w, r, "Couldn't add reply to thread: %s", err) } else { session.Flash(w, r, "Reply added to the thread!") + // If we're attaching a photo, link it to this reply CommentID. + if commentPhoto != nil { + commentPhoto.CommentID = reply.ID + if err := commentPhoto.Save(); err != nil { + log.Error("Couldn't save forum reply CommentPhoto.CommentID: %s", err) + } + } + // Notify watchers about this new post. for _, userID := range models.GetSubscribers("threads", thread.ID) { if userID == currentUser.ID { @@ -205,6 +322,14 @@ func NewPost() http.HandlerFunc { } else { session.Flash(w, r, "Thread created!") + // If we're attaching a photo, link it to this CommentID. + if commentPhoto != nil { + commentPhoto.CommentID = thread.CommentID + if err := commentPhoto.Save(); err != nil { + log.Error("Couldn't save forum post CommentPhoto.CommentID: %s", err) + } + } + // Subscribe the current user to responses on this thread. if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil { log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err) @@ -229,6 +354,9 @@ func NewPost() http.HandlerFunc { "IsPinned": isPinned, "IsExplicit": isExplicit, "IsNoReply": isNoReply, + + // Attached photo. + "CommentPhoto": commentPhoto, } if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/controller/forum/thread.go b/pkg/controller/forum/thread.go index 27e3e09..1a1cb22 100644 --- a/pkg/controller/forum/thread.go +++ b/pkg/controller/forum/thread.go @@ -81,6 +81,12 @@ func Thread() http.HandlerFunc { } commentLikeMap := models.MapLikes(currentUser, "comments", commentIDs) + // Get any photo attachments for these comments. + photos, err := models.MapCommentPhotos(comments) + if err != nil { + log.Error("Couldn't MapCommentPhotos: %s", err) + } + // Is the current user subscribed to notifications on this thread? _, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID) @@ -89,6 +95,7 @@ func Thread() http.HandlerFunc { "Thread": thread, "Comments": comments, "LikeMap": commentLikeMap, + "PhotoMap": photos, "Pager": pager, "IsSubscribed": isSubscribed, } diff --git a/pkg/models/comment_photo.go b/pkg/models/comment_photo.go new file mode 100644 index 0000000..db60d16 --- /dev/null +++ b/pkg/models/comment_photo.go @@ -0,0 +1,112 @@ +package models + +import ( + "time" +) + +// CommentPhoto table associates a photo attachment to a (forum) comment. +type CommentPhoto struct { + ID uint64 `gorm:"primaryKey"` + CommentID uint64 `gorm:"index"` + Filename string + Filesize int64 + CreatedAt time.Time + UpdatedAt time.Time + ExpiredAt time.Time +} + +// CreateCommentPhoto with most of the settings you want (not ID or timestamps) in the database. +func CreateCommentPhoto(tmpl CommentPhoto) (*CommentPhoto, error) { + p := &CommentPhoto{ + CommentID: tmpl.CommentID, + Filename: tmpl.Filename, + } + + result := DB.Create(p) + return p, result.Error +} + +// GetCommentPhoto by ID. +func GetCommentPhoto(id uint64) (*CommentPhoto, error) { + p := &CommentPhoto{} + result := DB.First(&p, id) + return p, result.Error +} + +// GetPhotos returns the comment photos for a given comment. +func (c *Comment) GetPhotos() ([]*CommentPhoto, error) { + mapping, err := MapCommentPhotos([]*Comment{c}) + if err != nil { + return nil, err + } + + return mapping.Get(c.ID), nil +} + +// CommentPhotoMap maps comment IDs to CommentPhotos. +type CommentPhotoMap map[uint64][]*CommentPhoto + +// Get like stats from the map. +func (lm CommentPhotoMap) Get(id uint64) []*CommentPhoto { + if stats, ok := lm[id]; ok { + return stats + } + return nil +} + +// MapCommentPhotos returns a map of photo attachments to a series of comments. +func MapCommentPhotos(comments []*Comment) (CommentPhotoMap, error) { + var ( + result = CommentPhotoMap{} // map[uint64][]*CommentPhoto{} + ps = []*CommentPhoto{} + IDs = []uint64{} + ) + + for _, c := range comments { + IDs = append(IDs, c.ID) + } + + res := DB.Model(&CommentPhoto{}).Where("comment_id IN ?", IDs).Find(&ps) + if res.Error != nil { + return nil, res.Error + } + + for _, row := range ps { + if _, ok := result[row.CommentID]; !ok { + result[row.CommentID] = []*CommentPhoto{} + } + result[row.CommentID] = append(result[row.CommentID], row) + } + + return result, nil +} + +// Save CommentPhoto. +func (p *CommentPhoto) Save() error { + result := DB.Save(p) + return result.Error +} + +// Delete CommentPhoto. +func (p *CommentPhoto) Delete() error { + result := DB.Delete(p) + return result.Error +} + +// GetOrphanedCommentPhotos gets all (up to 500) photos having a blank CommentID older than 24 hours. +func GetOrphanedCommentPhotos() ([]*CommentPhoto, int64, error) { + var ( + count int64 + cutoff = time.Now().Add(-24 * time.Hour) + ps = []*CommentPhoto{} + ) + + query := DB.Model(&CommentPhoto{}).Where("comment_id = 0 AND created_at < ?", cutoff) + query.Count(&count) + res := query.Limit(500).Find(&ps) + if res.Error != nil { + return nil, 0, res.Error + } + + return ps, count, res.Error +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 2dd56cd..b3c0099 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -23,4 +23,5 @@ func AutoMigrate() { DB.AutoMigrate(&Like{}) DB.AutoMigrate(&Notification{}) DB.AutoMigrate(&Subscription{}) + DB.AutoMigrate(&CommentPhoto{}) } diff --git a/pkg/router/router.go b/pkg/router/router.go index 1c12bda..8c9abdd 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -81,6 +81,7 @@ func New() http.Handler { mux.HandleFunc("/v1/users/me", api.LoginOK()) mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes())) mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification())) + mux.Handle("/v1/comment-photos/remove-orphaned", api.RemoveOrphanedCommentPhotos()) // Static files. mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath)))) diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 3d3b9ac..1c1f3c8 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -97,7 +97,6 @@ Manage Private Photos - NEW!
  • diff --git a/web/templates/forum/index.html b/web/templates/forum/index.html index 6b17b38..f1c3b1b 100644 --- a/web/templates/forum/index.html +++ b/web/templates/forum/index.html @@ -89,6 +89,13 @@ Privileged {{end}} + + {{if .PermitPhotos}} + + + Photos + + {{end}} diff --git a/web/templates/forum/new_post.html b/web/templates/forum/new_post.html index 3b65670..7950a47 100644 --- a/web/templates/forum/new_post.html +++ b/web/templates/forum/new_post.html @@ -54,7 +54,7 @@ {{end}} -
    + {{InputCSRF}} {{if not .Thread}} @@ -74,13 +74,65 @@

    Markdown formatting supported.

    + + {{if .Forum.PermitPhotos}} + + + + + + + +
    + +
    + +
    +
    + +
    +

    + Selected image: + +

    + + + +
    + {{end}} + {{if or (not .Thread) .EditThreadSettings}}
    {{if or .CurrentUser.IsAdmin (and .Forum (eq .Forum.OwnerID .CurrentUser.ID))}} @@ -144,5 +196,85 @@
    + + {{if .Forum.PermitPhotos}} + + {{end}} + {{end}} \ No newline at end of file diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html index 0cc8d44..c108e98 100644 --- a/web/templates/forum/thread.html +++ b/web/templates/forum/thread.html @@ -149,6 +149,22 @@ {{end}} + + {{$Photos := $Root.PhotoMap.Get .ID}} + {{if $Photos}} + {{range $Photos}} + {{if not .ExpiredAt.IsZero}} +
    + photo expired on {{.ExpiredAt.Format "2006-01-02"}} +
    + {{else}} +
    + +
    + {{end}} + {{end}} + {{end}} +
    @@ -243,7 +259,7 @@
    - + {{InputCSRF}}
    @@ -251,13 +267,51 @@

    Markdown formatting supported.

    + + {{if .Forum.PermitPhotos}} +
    + + + + + + +
    + +
    +
    + {{end}} +
    {{else if and (not .IsSiteGallery) (not .IsMyPrivateUnlockedFor)}} @@ -294,7 +293,6 @@ Grant {{.User.Username}} access to see my private photos - NEW
    {{else if and (not .IsSiteGallery) .IsMyPrivateUnlockedFor}} @@ -302,7 +300,6 @@ You had granted {{.User.Username}} access to see your private photos. Manage that here. - NEW
    {{end}} diff --git a/web/templates/photo/upload.html b/web/templates/photo/upload.html index 33ce691..c340580 100644 --- a/web/templates/photo/upload.html +++ b/web/templates/photo/upload.html @@ -367,7 +367,6 @@
    - @@ -388,7 +387,7 @@ $hiddenPreview = document.querySelector("#imagePreview"), $previewBox = document.querySelector("#previewBox"), $cropField = document.querySelector("#cropCoords"), - $dropArea = document.querySelector("#drop-modal") + $dropArea = document.querySelector("#drop-modal"), $body = document.querySelector("body"); // Common handler for file selection, either via input