diff --git a/back_office/comments_controller.go b/back_office/comments_controller.go index df87ad4397eb1179054ee143bfea11b8e1277b60..7a26014866702355c6cbeeb2901b887e7eb22571 100644 --- a/back_office/comments_controller.go +++ b/back_office/comments_controller.go @@ -7,7 +7,6 @@ package back_office import ( "net/http" - "github.com/alexedwards/scs/v2" "github.com/labstack/echo/v4" "go.uber.org/fx" "kita.gawa.moe/paweljw/vellum/commons" @@ -17,8 +16,8 @@ import ( ) type CommentsController struct { - Repo *repos.CommentRepo - SessionManager *scs.SessionManager + Repo *repos.CommentRepo + Flasher *commons.Flasher } func (c *CommentsController) SetupRoutes(e *echo.Echo) { @@ -31,26 +30,26 @@ func (c *CommentsController) delete(ctx echo.Context) error { comment := &models.Comment{} if err := c.Repo.Get(commentID, comment); err != nil { - commons.FlashError(c.SessionManager, ctx, "Comment not found") + c.Flasher.FlashError(ctx, "Comment not found") return ctx.Redirect(http.StatusSeeOther, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } document := comment.Document if err := c.Repo.Delete(commentID); err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to delete comment") + c.Flasher.FlashError(ctx, "Failed to delete comment") return ctx.Redirect(http.StatusSeeOther, commons.JoinPath([]string{config.ADMIN_DOCUMENTS_CONTROLLER_ROOT, "-", document.BakedPath})) } - commons.FlashSuccess(c.SessionManager, ctx, "Comment deleted") + c.Flasher.FlashSuccess(ctx, "Comment deleted") return ctx.Redirect(http.StatusSeeOther, commons.JoinPath([]string{config.ADMIN_DOCUMENTS_CONTROLLER_ROOT, "-", document.BakedPath})) } type NewCommentsControllerParams struct { fx.In - Repo *repos.CommentRepo - SessionManager *scs.SessionManager + Repo *repos.CommentRepo + Flasher *commons.Flasher } type NewCommentsControllerResult struct { @@ -62,8 +61,8 @@ type NewCommentsControllerResult struct { func NewCommentsController(p NewCommentsControllerParams) NewCommentsControllerResult { return NewCommentsControllerResult{ Controller: &CommentsController{ - Repo: p.Repo, - SessionManager: p.SessionManager, + Repo: p.Repo, + Flasher: p.Flasher, }, } } diff --git a/back_office/documents_controller.go b/back_office/documents_controller.go index ab6c23c63d9cc02f6ed9c54e0969bf8c053e0aa2..49ece6e3b904bbe2fdb97e7d825663016fb6c72e 100644 --- a/back_office/documents_controller.go +++ b/back_office/documents_controller.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/alexedwards/scs/v2" "github.com/labstack/echo/v4" "go.uber.org/fx" "go.uber.org/zap" @@ -23,11 +22,11 @@ import ( ) type DocumentsController struct { - Logger *zap.Logger - DocumentRepo *repos.DocumentRepo - MediumRepo *repos.MediumRepo - SessionManager *scs.SessionManager - View *DocumentsView + Logger *zap.Logger + DocumentRepo *repos.DocumentRepo + MediumRepo *repos.MediumRepo + View *DocumentsView + Flasher *commons.Flasher } func (c DocumentsController) SetupRoutes(e *echo.Echo) { @@ -58,7 +57,7 @@ func (c *DocumentsController) new(ctx echo.Context) error { if parentPath != "" { if err := c.DocumentRepo.GetByPath(parentPath, &parentDocument); err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to find parent document: "+parentPath) + c.Flasher.FlashError(ctx, "Failed to find parent document: "+parentPath) return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } } @@ -87,17 +86,17 @@ func (c *DocumentsController) create(ctx echo.Context) error { document, err := c.DocumentRepo.Create(params) if err != nil { c.Logger.Error("failed to create document", zap.Error(err)) - commons.FlashError(c.SessionManager, ctx, "Failed to create document: "+err.Error()) + c.Flasher.FlashError(ctx, "Failed to create document: "+err.Error()) if strings.Contains(err.Error(), "violates unique constraint") { slugError = "Full path is not unique" } } else { - commons.FlashSuccess(c.SessionManager, ctx, "Document created") + c.Flasher.FlashSuccess(ctx, "Document created") return ctx.Redirect(http.StatusFound, commons.JoinPath([]string{config.ADMIN_DOCUMENTS_CONTROLLER_ROOT, "-", document.BakedPath})) } } else { - commons.FlashError(c.SessionManager, ctx, "Could not create document") + c.Flasher.FlashError(ctx, "Could not create document") } // Form somehow invalid, re-render form @@ -106,7 +105,7 @@ func (c *DocumentsController) create(ctx echo.Context) error { if parentPath != "" { if err := c.DocumentRepo.GetByPath(parentPath, &parentDocument); err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to find parent document: "+parentPath) + c.Flasher.FlashError(ctx, "Failed to find parent document: "+parentPath) return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } } @@ -140,7 +139,7 @@ func (c *DocumentsController) edit(ctx echo.Context) error { editDocument := models.Document{} if err := c.DocumentRepo.GetByPath(editPath, &editDocument); err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to find document: "+editPath) + c.Flasher.FlashError(ctx, "Failed to find document: "+editPath) return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } @@ -184,11 +183,11 @@ func (c *DocumentsController) update(ctx echo.Context) error { updatedDocument, err := c.DocumentRepo.Update(form.ToParams()) if err != nil { c.Logger.Error("failed to update document", zap.Error(err)) - commons.FlashError(c.SessionManager, ctx, "Failed to update document") + c.Flasher.FlashError(ctx, "Failed to update document") return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } - commons.FlashSuccess(c.SessionManager, ctx, "Document updated") + c.Flasher.FlashSuccess(ctx, "Document updated") return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT+"/-"+updatedDocument.BakedPath) } else { @@ -206,7 +205,7 @@ func (c *DocumentsController) update(ctx echo.Context) error { IsEdit: true, } - commons.FlashError(c.SessionManager, ctx, "Could not update document") + c.Flasher.FlashError(ctx, "Could not update document") return ctx.HTML(http.StatusOK, c.View.Form(ctx, metadata)) } @@ -228,9 +227,9 @@ func (c *DocumentsController) delete(ctx echo.Context) error { path := commons.JoinPath([]string{"/", ctx.Param("*")}) if err := c.DocumentRepo.DeleteByPath(path); err != nil { c.Logger.Error("failed to delete document", zap.Error(err)) - commons.FlashError(c.SessionManager, ctx, "Failed to delete document: "+err.Error()) + c.Flasher.FlashError(ctx, "Failed to delete document: "+err.Error()) } else { - commons.FlashSuccess(c.SessionManager, ctx, "Document deleted") + c.Flasher.FlashSuccess(ctx, "Document deleted") } return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) @@ -241,19 +240,19 @@ func (c *DocumentsController) move(ctx echo.Context) error { moveDocument := models.Document{} if err := c.DocumentRepo.GetByPath(movePath, &moveDocument); err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to find document: "+movePath) + c.Flasher.FlashError(ctx, "Failed to find document: "+movePath) return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } if len(moveDocument.Children) > 0 { - commons.FlashError(c.SessionManager, ctx, "Cannot move document with children") + c.Flasher.FlashError(ctx, "Cannot move document with children") return ctx.Redirect(http.StatusFound, commons.JoinPath([]string{config.ADMIN_DOCUMENTS_CONTROLLER_ROOT, "-", moveDocument.BakedPath})) } targets, err := c.DocumentRepo.GetAll() if err != nil { c.Logger.Error("failed to get all documents", zap.Error(err)) - commons.FlashError(c.SessionManager, ctx, "Failed to get all documents") + c.Flasher.FlashError(ctx, "Failed to get all documents") return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } @@ -265,50 +264,50 @@ func (c *DocumentsController) movePost(ctx echo.Context) error { moveDocument := models.Document{} if err := c.DocumentRepo.GetByPath(movePath, &moveDocument); err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to find document: "+movePath) + c.Flasher.FlashError(ctx, "Failed to find document: "+movePath) return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } if moveDocument.ParentID.Int64 == 0 { - commons.FlashError(c.SessionManager, ctx, "Cannot move root document") + c.Flasher.FlashError(ctx, "Cannot move root document") return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } if len(moveDocument.Children) > 0 { - commons.FlashError(c.SessionManager, ctx, "Cannot move document with children") + c.Flasher.FlashError(ctx, "Cannot move document with children") return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } targetID, err := strconv.ParseUint(ctx.FormValue("target"), 10, 64) if err != nil { - commons.FlashError(c.SessionManager, ctx, "Invalid target") + c.Flasher.FlashError(ctx, "Invalid target") return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } targetDocument := models.Document{} if err := c.DocumentRepo.Get(uint(targetID), &targetDocument); err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to find target document: "+ctx.FormValue("target")) + c.Flasher.FlashError(ctx, "Failed to find target document: "+ctx.FormValue("target")) return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } if err := c.DocumentRepo.Move(&moveDocument, targetDocument); err != nil { c.Logger.Error("failed to move document", zap.Error(err)) - commons.FlashError(c.SessionManager, ctx, "Failed to move document: "+err.Error()) + c.Flasher.FlashError(ctx, "Failed to move document: "+err.Error()) return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } - commons.FlashSuccess(c.SessionManager, ctx, "Document moved") + c.Flasher.FlashSuccess(ctx, "Document moved") return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT+"/-"+moveDocument.BakedPath) } type NewDocumentsControllerParams struct { fx.In - Logger *zap.Logger - View *DocumentsView - DocumentRepo *repos.DocumentRepo - MediumRepo *repos.MediumRepo - SessionManager *scs.SessionManager + Logger *zap.Logger + View *DocumentsView + DocumentRepo *repos.DocumentRepo + MediumRepo *repos.MediumRepo + Flasher *commons.Flasher } type NewDocumentsControllerResult struct { @@ -319,11 +318,11 @@ type NewDocumentsControllerResult struct { func NewDocumentsController(p NewDocumentsControllerParams) NewDocumentsControllerResult { return NewDocumentsControllerResult{ Controller: DocumentsController{ - Logger: p.Logger, - View: p.View, - DocumentRepo: p.DocumentRepo, - MediumRepo: p.MediumRepo, - SessionManager: p.SessionManager, + Logger: p.Logger, + View: p.View, + DocumentRepo: p.DocumentRepo, + MediumRepo: p.MediumRepo, + Flasher: p.Flasher, }, } } diff --git a/back_office/media_collections_controller.go b/back_office/media_collections_controller.go index 5123b2889bdb8402e7edf581b5a802ed57c6ea29..ecb6a3854e906d69fd29e9c5c69ac76fb9d0d161 100644 --- a/back_office/media_collections_controller.go +++ b/back_office/media_collections_controller.go @@ -8,7 +8,6 @@ package back_office import ( "net/http" - "github.com/alexedwards/scs/v2" "github.com/labstack/echo/v4" "go.uber.org/fx" "kita.gawa.moe/paweljw/vellum/commons" @@ -17,9 +16,9 @@ import ( ) type MediaCollectionsController struct { - View *MediaCollectionsView - MediumRepo *repos.MediumRepo - SessionManager *scs.SessionManager + View *MediaCollectionsView + MediumRepo *repos.MediumRepo + Flasher *commons.Flasher } func (c *MediaCollectionsController) SetupRoutes(e *echo.Echo) { @@ -60,14 +59,14 @@ func (c *MediaCollectionsController) delete(ctx echo.Context) error { slug := ctx.Param("slug") collection, err := c.MediumRepo.GetCollectionByName(slug) if err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to delete media collection: "+err.Error()) + c.Flasher.FlashError(ctx, "Failed to delete media collection: "+err.Error()) return ctx.Redirect(http.StatusSeeOther, config.ADMIN_MEDIA_CONTROLLER_ROOT) } if err := c.MediumRepo.Delete(&collection); err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to delete media collection: "+err.Error()) + c.Flasher.FlashError(ctx, "Failed to delete media collection: "+err.Error()) return ctx.Redirect(http.StatusSeeOther, config.ADMIN_MEDIA_CONTROLLER_ROOT) } - commons.FlashSuccess(c.SessionManager, ctx, "Media collection deleted") + c.Flasher.FlashSuccess(ctx, "Media collection deleted") return ctx.Redirect(http.StatusSeeOther, config.ADMIN_MEDIA_CONTROLLER_ROOT) } @@ -86,13 +85,13 @@ func (c *MediaCollectionsController) create(ctx echo.Context) error { if v.Validate(form) { err := c.MediumRepo.CreateMediaCollection(form.ToParams()) if err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to create media collection: "+err.Error()) + c.Flasher.FlashError(ctx, "Failed to create media collection: "+err.Error()) return ctx.HTML(http.StatusOK, c.View.New(ctx, MediaCollectionsViewNewParams{ Name: form.Name, NameError: v.Messages("Name"), })) } - commons.FlashSuccess(c.SessionManager, ctx, "Media collection created") + c.Flasher.FlashSuccess(ctx, "Media collection created") return ctx.Redirect(http.StatusSeeOther, config.ADMIN_MEDIA_CONTROLLER_ROOT) } @@ -105,9 +104,9 @@ func (c *MediaCollectionsController) create(ctx echo.Context) error { type NewMediaCollectionsControllerParams struct { fx.In - View *MediaCollectionsView - MediumRepo *repos.MediumRepo - SessionManager *scs.SessionManager + View *MediaCollectionsView + MediumRepo *repos.MediumRepo + Flasher *commons.Flasher } type NewMediaCollectionsControllerResult struct { @@ -119,9 +118,9 @@ type NewMediaCollectionsControllerResult struct { func NewMediaCollectionsController(p NewMediaCollectionsControllerParams) NewMediaCollectionsControllerResult { return NewMediaCollectionsControllerResult{ Controller: &MediaCollectionsController{ - View: p.View, - MediumRepo: p.MediumRepo, - SessionManager: p.SessionManager, + View: p.View, + MediumRepo: p.MediumRepo, + Flasher: p.Flasher, }, } } diff --git a/back_office/media_controller.go b/back_office/media_controller.go index fca5c8a81386953063f8db30df642f79de5dfa4c..3173766f9245375d08bb5e4a98a2faedd7e0d432 100644 --- a/back_office/media_controller.go +++ b/back_office/media_controller.go @@ -13,7 +13,6 @@ import ( "os" "path/filepath" - "github.com/alexedwards/scs/v2" "github.com/labstack/echo/v4" "go.uber.org/fx" "go.uber.org/zap" @@ -23,11 +22,11 @@ import ( ) type MediaController struct { - Logger *zap.Logger - MediumRepo *repos.MediumRepo - SessionManager *scs.SessionManager - Config *config.Config - View *MediaView + Logger *zap.Logger + MediumRepo *repos.MediumRepo + Config *config.Config + View *MediaView + Flasher *commons.Flasher } func (c *MediaController) SetupRoutes(e *echo.Echo) { @@ -57,7 +56,7 @@ func (c *MediaController) create(ctx echo.Context) error { slug := ctx.Param("slug") collection, err := c.MediumRepo.GetCollectionByName(slug) if err != nil { - commons.FlashError(c.SessionManager, ctx, "Media collection not found") + c.Flasher.FlashError(ctx, "Media collection not found") return ctx.Redirect(http.StatusSeeOther, config.ADMIN_MEDIA_CONTROLLER_ROOT) } file, err := ctx.FormFile("file") @@ -103,11 +102,11 @@ func (c *MediaController) create(ctx echo.Context) error { } if err := c.MediumRepo.CreateMedium(tmp); err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to create medium: "+err.Error()) + c.Flasher.FlashError(ctx, "Failed to create medium: "+err.Error()) return ctx.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/new", config.ADMIN_MEDIA_CONTROLLER_ROOT, slug)) } - commons.FlashSuccess(c.SessionManager, ctx, fmt.Sprintf("Media created, %d bytes written", written)) + c.Flasher.FlashSuccess(ctx, fmt.Sprintf("Media created, %d bytes written", written)) return ctx.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s", config.ADMIN_MEDIA_CONTROLLER_ROOT, slug)) } @@ -139,17 +138,17 @@ func (c *MediaController) delete(ctx echo.Context) error { if err := c.MediumRepo.DeleteMedium(&item); err != nil { return ctx.String(http.StatusInternalServerError, "Failed to delete media item") } - commons.FlashSuccess(c.SessionManager, ctx, "Media item deleted") + c.Flasher.FlashSuccess(ctx, "Media item deleted") return ctx.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s", config.ADMIN_MEDIA_CONTROLLER_ROOT, slug)) } type NewMediaControllerParams struct { fx.In - Logger *zap.Logger - View *MediaView - MediumRepo *repos.MediumRepo - SessionManager *scs.SessionManager - Config *config.Config + Logger *zap.Logger + View *MediaView + MediumRepo *repos.MediumRepo + Config *config.Config + Flasher *commons.Flasher } type NewMediaControllerResult struct { @@ -160,11 +159,11 @@ type NewMediaControllerResult struct { func NewMediaController(p NewMediaControllerParams) NewMediaControllerResult { return NewMediaControllerResult{ Controller: &MediaController{ - Logger: p.Logger, - View: p.View, - MediumRepo: p.MediumRepo, - SessionManager: p.SessionManager, - Config: p.Config, + Logger: p.Logger, + View: p.View, + MediumRepo: p.MediumRepo, + Config: p.Config, + Flasher: p.Flasher, }, } } diff --git a/back_office/search_controller.go b/back_office/search_controller.go index 14e448fe5a667dfdd4bc77fd71217484da80936d..267677ac8dfb62c91e1778c0fd02db7fba44af33 100644 --- a/back_office/search_controller.go +++ b/back_office/search_controller.go @@ -8,7 +8,6 @@ package back_office import ( "net/http" - "github.com/alexedwards/scs/v2" "github.com/labstack/echo/v4" "go.uber.org/fx" "kita.gawa.moe/paweljw/vellum/commons" @@ -19,7 +18,7 @@ import ( type SearchController struct { DocumentRepo *repos.DocumentRepo View *SearchView - Session *scs.SessionManager + Flasher *commons.Flasher } func (c SearchController) SetupRoutes(ctx *echo.Echo) { @@ -30,7 +29,7 @@ func (c *SearchController) search(ctx echo.Context) error { query := ctx.QueryParam("query") documents, err := c.DocumentRepo.Search(query) if err != nil { - commons.FlashError(c.Session, ctx, "Failed to search documents") + c.Flasher.FlashError(ctx, "Failed to search documents") return ctx.Redirect(http.StatusSeeOther, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } return ctx.HTML(http.StatusOK, c.View.Index(ctx, SearchViewIndexParams{ @@ -43,7 +42,7 @@ type NewSearchControllerParams struct { fx.In DocumentRepo *repos.DocumentRepo View *SearchView - Session *scs.SessionManager + Flasher *commons.Flasher } type NewSearchControllerResult struct { @@ -56,7 +55,7 @@ func NewSearchController(params NewSearchControllerParams) NewSearchControllerRe Controller: SearchController{ DocumentRepo: params.DocumentRepo, View: params.View, - Session: params.Session, + Flasher: params.Flasher, }, } } diff --git a/back_office/sessions_controller.go b/back_office/sessions_controller.go index 03613e52d8cf2acc68d156c3740864f38d92ea0f..d3be207d7747ebbaea3cfd2dde94857165643380 100644 --- a/back_office/sessions_controller.go +++ b/back_office/sessions_controller.go @@ -19,6 +19,7 @@ import ( type SessionsController struct { SessionManager *scs.SessionManager View *SessionsView + Flasher *commons.Flasher } func (c *SessionsController) SetupRoutes(ctx *echo.Echo) { @@ -52,7 +53,7 @@ func (c *SessionsController) deleteOtherSessions(ctx echo.Context) error { return nil }) - commons.FlashSuccess(c.SessionManager, ctx, "All other sessions have been signed out") + c.Flasher.FlashSuccess(ctx, "All other sessions have been signed out") return ctx.Redirect(http.StatusSeeOther, config.ADMIN_SESSIONS_CONTROLLER_ROOT) } @@ -62,6 +63,7 @@ type NewSessionsControllerParams struct { SessionManager *scs.SessionManager View *SessionsView + Flasher *commons.Flasher } type NewSessionsControllerResult struct { @@ -75,6 +77,7 @@ func NewSessionsController(p NewSessionsControllerParams) NewSessionsControllerR Controller: &SessionsController{ SessionManager: p.SessionManager, View: p.View, + Flasher: p.Flasher, }, } } diff --git a/commons/colors_test.go b/commons/colors_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a23cf943d1af159a574d323bf2f9bbe3489de451 --- /dev/null +++ b/commons/colors_test.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> +// +// SPDX-License-Identifier: AGPL-3.0-only + +package commons_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "kita.gawa.moe/paweljw/vellum/commons" +) + +func TestRepresentativeColorHSV(t *testing.T) { + // Test with known input to verify consistent output + color := commons.RepresentativeColorHSV("test", 20, 95) + assert.Equal(t, "#f9f8c7", color) + + // Test that same input produces same output + color2 := commons.RepresentativeColorHSV("test", 20, 95) + assert.Equal(t, color, color2) + + // Test different input produces different output + color3 := commons.RepresentativeColorHSV("different", 20, 95) + assert.NotEqual(t, color, color3) + + // Test with different saturation and value + color4 := commons.RepresentativeColorHSV("test", 50, 80) + assert.NotEqual(t, color, color4) +} diff --git a/commons/email.go b/commons/email.go deleted file mode 100644 index eaf1debaf60e14049bcd80cb2e6f30d54e7e6030..0000000000000000000000000000000000000000 --- a/commons/email.go +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> -// -// SPDX-License-Identifier: AGPL-3.0-only - -package commons - -import ( - "crypto/tls" - - "gopkg.in/gomail.v2" - "kita.gawa.moe/paweljw/vellum/config" -) - -func SendEmail(config *config.Config, to, subject, body string) { - message := gomail.NewMessage() - - // Set email headers - message.SetHeader("From", config.EmailFrom) - message.SetHeader("To", to) - message.SetHeader("Subject", subject) - - // Set email body - message.SetBody("text/plain", body) - - dialer := gomail.NewDialer(config.EmailHost, config.EmailPort, config.EmailUser, config.EmailPass) - dialer.TLSConfig = &tls.Config{InsecureSkipVerify: true} - - // Send the email - if err := dialer.DialAndSend(message); err != nil { - panic(err) - } -} diff --git a/commons/fixtures/.gitignore b/commons/fixtures/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ad684493ca6e869c7fc74665210c380b2b8a5549 --- /dev/null +++ b/commons/fixtures/.gitignore @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> +# +# SPDX-License-Identifier: AGPL-3.0-only + +*.png +!1x1.png +!og_image_reference.png +!.gitignore \ No newline at end of file diff --git a/commons/fixtures/1x1.png b/commons/fixtures/1x1.png new file mode 100644 index 0000000000000000000000000000000000000000..33f19edad6519edcb0dc7e238fd101a193437165 Binary files /dev/null and b/commons/fixtures/1x1.png differ diff --git a/commons/fixtures/1x1.png.license b/commons/fixtures/1x1.png.license new file mode 100644 index 0000000000000000000000000000000000000000..6e33d612e4c13403c468cfe6c094a22b9a15ce3f --- /dev/null +++ b/commons/fixtures/1x1.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> + +SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/commons/fixtures/og_image_reference.png b/commons/fixtures/og_image_reference.png new file mode 100644 index 0000000000000000000000000000000000000000..f41dfa310d972ff60914c43fb216a9dddc148df5 Binary files /dev/null and b/commons/fixtures/og_image_reference.png differ diff --git a/commons/fixtures/og_image_reference.png.license b/commons/fixtures/og_image_reference.png.license new file mode 100644 index 0000000000000000000000000000000000000000..6e33d612e4c13403c468cfe6c094a22b9a15ce3f --- /dev/null +++ b/commons/fixtures/og_image_reference.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> + +SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/commons/flasher.go b/commons/flasher.go new file mode 100644 index 0000000000000000000000000000000000000000..ce629fe8114a07a94013f6eddf1cbf0b1c2ff48b --- /dev/null +++ b/commons/flasher.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net> +// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> +// +// SPDX-License-Identifier: AGPL-3.0-only + +package commons + +import ( + "context" + "net/http" + + "github.com/alexedwards/scs/v2" + "go.uber.org/fx" + "kita.gawa.moe/paweljw/vellum/config" +) + +type Flasher struct { + SessionManager ISessionManager +} + +type ISessionManager interface { + Put(ctx context.Context, key string, val interface{}) + GetString(ctx context.Context, key string) string +} + +type IContext interface { + Request() *http.Request +} + +func (f *Flasher) FlashSuccess(ctx IContext, message string) { + f.SessionManager.Put(ctx.Request().Context(), config.FLASH_SUCCESS_KEY, message) +} + +func (f *Flasher) FlashError(ctx IContext, message string) { + f.SessionManager.Put(ctx.Request().Context(), config.FLASH_ERROR_KEY, message) +} + +func (f *Flasher) GetFlashes(ctx IContext) (string, string) { + return f.SessionManager.GetString(ctx.Request().Context(), config.FLASH_SUCCESS_KEY), + f.SessionManager.GetString(ctx.Request().Context(), config.FLASH_ERROR_KEY) +} + +type NewFlasherParams struct { + fx.In + + SessionManager *scs.SessionManager +} + +type NewFlasherResult struct { + fx.Out + + Flasher *Flasher +} + +func NewFlasher(p NewFlasherParams) NewFlasherResult { + return NewFlasherResult{ + Flasher: &Flasher{ + SessionManager: p.SessionManager, + }, + } +} diff --git a/commons/flasher_test.go b/commons/flasher_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2b6fc772d98d03bb8995f6a72d59b4bd5cde47aa --- /dev/null +++ b/commons/flasher_test.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> +// +// SPDX-License-Identifier: AGPL-3.0-only + +package commons_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "kita.gawa.moe/paweljw/vellum/commons" + "kita.gawa.moe/paweljw/vellum/config" +) + +type MockSessionManager struct { + mock.Mock +} + +func (m *MockSessionManager) Put(ctx context.Context, key string, val interface{}) { + m.Called(ctx, key, val) +} + +func (m *MockSessionManager) GetString(ctx context.Context, key string) string { + args := m.Called(ctx, key) + return args.String(0) +} + +type MockContext struct { + mock.Mock +} + +func (m *MockContext) Request() *http.Request { + args := m.Called() + return args.Get(0).(*http.Request) +} + +type FlasherTestSuite struct { + suite.Suite + sut *commons.Flasher + sessionManager *MockSessionManager + ctx *MockContext + reqCtx context.Context +} + +func (s *FlasherTestSuite) SetupTest() { + s.sessionManager = new(MockSessionManager) + s.sut = &commons.Flasher{ + SessionManager: s.sessionManager, + } + + s.ctx = new(MockContext) + s.reqCtx = context.Background() + + req := &http.Request{} + s.ctx.On("Request").Return(req) +} + +func (s *FlasherTestSuite) TestFlashSuccess() { + // Given + message := "test success message" + s.sessionManager.On("Put", s.reqCtx, config.FLASH_SUCCESS_KEY, message).Once() + + // When + s.sut.FlashSuccess(s.ctx, message) + + // Then + s.sessionManager.AssertExpectations(s.T()) +} + +func (s *FlasherTestSuite) TestFlashError() { + // Given + message := "test error message" + s.sessionManager.On("Put", s.reqCtx, config.FLASH_ERROR_KEY, message).Once() + + // When + s.sut.FlashError(s.ctx, message) + + // Then + s.sessionManager.AssertExpectations(s.T()) +} + +func (s *FlasherTestSuite) TestGetFlashes() { + // Given + successMsg := "success message" + errorMsg := "error message" + + s.sessionManager.On("GetString", s.reqCtx, config.FLASH_SUCCESS_KEY).Return(successMsg).Once() + s.sessionManager.On("GetString", s.reqCtx, config.FLASH_ERROR_KEY).Return(errorMsg).Once() + + // When + success, error := s.sut.GetFlashes(s.ctx) + + // Then + s.Equal(successMsg, success) + s.Equal(errorMsg, error) + s.sessionManager.AssertExpectations(s.T()) +} + +func TestFlasherTestSuite(t *testing.T) { + suite.Run(t, new(FlasherTestSuite)) +} diff --git a/commons/flashes.go b/commons/flashes.go deleted file mode 100644 index 5821b987e856f1eb8956a46be78ef28e1c0c4678..0000000000000000000000000000000000000000 --- a/commons/flashes.go +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net> -// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> -// -// SPDX-License-Identifier: AGPL-3.0-only - -package commons - -import ( - "github.com/alexedwards/scs/v2" - "github.com/labstack/echo/v4" - "kita.gawa.moe/paweljw/vellum/config" -) - -func FlashSuccess(ses *scs.SessionManager, ctx echo.Context, message string) { - ses.Put(ctx.Request().Context(), config.FLASH_SUCCESS_KEY, message) -} - -func FlashError(ses *scs.SessionManager, ctx echo.Context, message string) { - ses.Put(ctx.Request().Context(), config.FLASH_ERROR_KEY, message) -} diff --git a/commons/hcaptcha.go b/commons/hcaptcha_verifier.go similarity index 54% rename from commons/hcaptcha.go rename to commons/hcaptcha_verifier.go index dbd974e9fba7801413acc160c6bd9db890c07dd4..de0f93093752aafcfc3bd47e784f359fb094a0cf 100644 --- a/commons/hcaptcha.go +++ b/commons/hcaptcha_verifier.go @@ -11,17 +11,23 @@ import ( "net/url" "strings" + "go.uber.org/fx" "kita.gawa.moe/paweljw/vellum/config" ) -func VerifyHCaptcha(token string, ip string, config *config.Config) error { +type HCaptchaVerifier struct { + Config *config.Config + HCaptchaURL string +} + +func (v *HCaptchaVerifier) Verify(token string, ip string) error { if token == "" { return errors.New("token is required") } - resp, err := http.PostForm("https://api.hcaptcha.com/siteverify", url.Values{ - "secret": []string{config.HCaptchaSecretKey}, - "sitekey": []string{config.HCaptchaSiteKey}, + resp, err := http.PostForm(v.HCaptchaURL, url.Values{ + "secret": []string{v.Config.HCaptchaSecretKey}, + "sitekey": []string{v.Config.HCaptchaSiteKey}, "response": []string{token}, "remoteip": []string{ip}, }) @@ -46,3 +52,24 @@ func VerifyHCaptcha(token string, ip string, config *config.Config) error { return nil } + +type NewHCaptchaVerifierParams struct { + fx.In + + Config *config.Config +} + +type NewHCaptchaVerifierResult struct { + fx.Out + + HCaptchaVerifier *HCaptchaVerifier +} + +func NewHCaptchaVerifier(p NewHCaptchaVerifierParams) NewHCaptchaVerifierResult { + return NewHCaptchaVerifierResult{ + HCaptchaVerifier: &HCaptchaVerifier{ + Config: p.Config, + HCaptchaURL: "https://api.hcaptcha.com/siteverify", + }, + } +} diff --git a/commons/hcaptcha_verifier_test.go b/commons/hcaptcha_verifier_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c55642752aefab4fb7197443ffc54fa0c11d3e1b --- /dev/null +++ b/commons/hcaptcha_verifier_test.go @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> +// +// SPDX-License-Identifier: AGPL-3.0-only + +package commons_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + "kita.gawa.moe/paweljw/vellum/commons" + "kita.gawa.moe/paweljw/vellum/config" +) + +type HCaptchaVerifierTestSuite struct { + suite.Suite + sut *commons.HCaptchaVerifier +} + +func (s *HCaptchaVerifierTestSuite) SetupTest() { + s.sut = &commons.HCaptchaVerifier{ + Config: &config.Config{ + HCaptchaSecretKey: "test-secret", + HCaptchaSiteKey: "test-site-key", + }, + } +} + +func (s *HCaptchaVerifierTestSuite) TestVerifyWithEmptyToken() { + err := s.sut.Verify("", "127.0.0.1") + s.Require().Error(err) + s.Require().ErrorContains(err, "token is required") +} + +func (s *HCaptchaVerifierTestSuite) TestVerifyWithInvalidToken() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and content type + s.Require().Equal(http.MethodPost, r.Method) + s.Require().Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + + // Verify form values + err := r.ParseForm() + s.Require().NoError(err) + s.Require().Equal("test-secret", r.Form.Get("secret")) + s.Require().Equal("test-site-key", r.Form.Get("sitekey")) + s.Require().Equal("invalid-token", r.Form.Get("response")) + s.Require().Equal("127.0.0.1", r.Form.Get("remoteip")) + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":false,"error-codes":["invalid-token"]}`)) + })) + defer server.Close() + + // Override hcaptcha API endpoint for test + http.DefaultServeMux.HandleFunc("/siteverify", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":false,"error-codes":["invalid-token"]}`)) + }) + + s.sut.HCaptchaURL = server.URL + "/siteverify" + + err := s.sut.Verify("invalid-token", "127.0.0.1") + + s.Require().Error(err) + s.Require().ErrorContains(err, "hcaptcha verification failed: invalid-token") +} + +func (s *HCaptchaVerifierTestSuite) TestVerifyWithValidToken() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and content type + s.Require().Equal(http.MethodPost, r.Method) + s.Require().Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + + // Verify form values + err := r.ParseForm() + s.Require().NoError(err) + s.Require().Equal("test-secret", r.Form.Get("secret")) + s.Require().Equal("test-site-key", r.Form.Get("sitekey")) + s.Require().Equal("valid-token", r.Form.Get("response")) + s.Require().Equal("127.0.0.1", r.Form.Get("remoteip")) + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":true}`)) + })) + defer server.Close() + + s.sut.HCaptchaURL = server.URL + "/siteverify" + + err := s.sut.Verify("valid-token", "127.0.0.1") + s.Require().NoError(err) +} + +func (s *HCaptchaVerifierTestSuite) TestVerifyWithInvalidJSONResponse() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and content type + s.Require().Equal(http.MethodPost, r.Method) + s.Require().Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + + // Verify form values + err := r.ParseForm() + s.Require().NoError(err) + s.Require().Equal("test-secret", r.Form.Get("secret")) + s.Require().Equal("test-site-key", r.Form.Get("sitekey")) + s.Require().Equal("valid-token", r.Form.Get("response")) + s.Require().Equal("127.0.0.1", r.Form.Get("remoteip")) + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`invalid json`)) + })) + defer server.Close() + + s.sut.HCaptchaURL = server.URL + "/siteverify" + + err := s.sut.Verify("valid-token", "127.0.0.1") + s.Require().Error(err) + s.Require().ErrorContains(err, "failed to decode hcaptcha response:") +} + +func TestHCaptchaVerifierTestSuite(t *testing.T) { + suite.Run(t, new(HCaptchaVerifierTestSuite)) +} diff --git a/commons/image.go b/commons/image_version.go similarity index 70% rename from commons/image.go rename to commons/image_version.go index 32479293b491652ff8fc50d5a3755a646b9d5034..70b0421c59c9e64459d2b46890d69d45334f3591 100644 --- a/commons/image.go +++ b/commons/image_version.go @@ -16,7 +16,7 @@ import ( "github.com/disintegration/imaging" ) -type ImageOptions struct { +type ImageVersion struct { Width int Height int Fit bool @@ -26,53 +26,55 @@ type ImageOptions struct { BasePath string } -func (o *ImageOptions) Apply() (string, error) { - if !o.Any() { +func (o *ImageVersion) Get() (string, error) { + if !o.any() { return o.BasePath, nil } newPath := o.applyToPath() - if o.VersionDoesntExist() { - src, err := imaging.Open(o.BasePath) - if err != nil { - return "", err - } - - var dst *image.NRGBA - if o.ApplyFill { - dst = imaging.Fill(src, o.Width, o.Height, o.Fill, imaging.Lanczos) - } else if o.Fit { - dst = imaging.Fit(src, o.Width, o.Height, imaging.Lanczos) - } else { - dst = imaging.Resize(src, o.Width, o.Height, imaging.Lanczos) - } - - err = imaging.Save(dst, newPath) - if err != nil { - return "", err - } + if o.Exists() { + return newPath, nil + } + + src, err := imaging.Open(o.BasePath) + if err != nil { + return "", err + } + + var dst *image.NRGBA + if o.ApplyFill { + dst = imaging.Fill(src, o.Width, o.Height, o.Fill, imaging.Lanczos) + } else if o.Fit { + dst = imaging.Fit(src, o.Width, o.Height, imaging.Lanczos) + } else { + dst = imaging.Resize(src, o.Width, o.Height, imaging.Lanczos) + } + + err = imaging.Save(dst, newPath) + if err != nil { + return "", err } return newPath, nil } -func (o *ImageOptions) VersionDoesntExist() bool { +func (o *ImageVersion) Exists() bool { _, err := os.Stat(o.applyToPath()) - return os.IsNotExist(err) + return !os.IsNotExist(err) } -func (o *ImageOptions) Any() bool { +func (o *ImageVersion) any() bool { return o.Width > 0 || o.Height > 0 || o.Fit || o.ApplyFill } -func (o *ImageOptions) applyToPath() string { +func (o *ImageVersion) applyToPath() string { ext := filepath.Ext(o.BasePath) basePath := strings.TrimSuffix(o.BasePath, ext) - opts := o.ToPathSuffix() + opts := o.pathSuffix() return fmt.Sprintf("%s_%s%s", basePath, opts, ext) } -func (o *ImageOptions) ToPathSuffix() string { +func (o *ImageVersion) pathSuffix() string { opts := []string{} if o.Width > 0 { opts = append(opts, fmt.Sprintf("w%d", o.Width)) @@ -89,11 +91,11 @@ func (o *ImageOptions) ToPathSuffix() string { return strings.Join(opts, "_") } -func (o *ImageOptions) isValid() bool { +func (o *ImageVersion) isValid() bool { return (o.Width > 0 && o.Height > 0) || (o.Width == 0 && o.Height == 0) } -func NewImageOptions(width, height, fit, fill, basePath string) (*ImageOptions, error) { +func NewImageVersion(width, height, fit, fill, basePath string) (*ImageVersion, error) { if (width == "" && height != "") || (width != "" && height == "") { return nil, errors.New("if one of width or height is provided, both must be provided") } @@ -128,7 +130,7 @@ func NewImageOptions(width, height, fit, fill, basePath string) (*ImageOptions, return nil, errors.New("invalid fill value") } - opts := &ImageOptions{ + opts := &ImageVersion{ Width: widthInt, Height: heightInt, Fit: fit != "", diff --git a/commons/image_version_test.go b/commons/image_version_test.go new file mode 100644 index 0000000000000000000000000000000000000000..885dea163ddb5810f956f17a5ceaeb50f52725dc --- /dev/null +++ b/commons/image_version_test.go @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> +// +// SPDX-License-Identifier: AGPL-3.0-only + +package commons_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" + "kita.gawa.moe/paweljw/vellum/commons" +) + +const testImagePath = "fixtures/1x1.png" +const testImagePattern = "fixtures/1x1_*.png" + +type ImageVersionTestSuite struct { + suite.Suite + testImagePath string +} + +func (s *ImageVersionTestSuite) SetupTest() { + s.testImagePath = testImagePath + cleanUpGeneratedFiles() +} + +func (s *ImageVersionTestSuite) TearDownTest() { + cleanUpGeneratedFiles() +} + +func (s *ImageVersionTestSuite) TestNewImageVersionValidation() { + tests := []struct { + name string + width string + height string + fit string + fill string + expectError bool + errorMsg string + }{ + { + name: "valid empty options", + width: "", + height: "", + fit: "", + fill: "", + expectError: false, + }, + { + name: "valid width and height", + width: "100", + height: "100", + fit: "", + fill: "", + expectError: false, + }, + { + name: "invalid width only", + width: "100", + height: "", + expectError: true, + errorMsg: "if one of width or height is provided, both must be provided", + }, + { + name: "invalid height only", + width: "", + height: "100", + expectError: true, + errorMsg: "if one of width or height is provided, both must be provided", + }, + { + name: "invalid fill value", + width: "100", + height: "100", + fill: "invalid", + expectError: true, + errorMsg: "invalid fill value", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + iv, err := commons.NewImageVersion(tt.width, tt.height, tt.fit, tt.fill, s.testImagePath) + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(iv) + } else { + s.NoError(err) + s.NotNil(iv) + } + }) + } +} + +func (s *ImageVersionTestSuite) TestImageVersionGet() { + tests := []struct { + name string + width string + height string + fit string + fill string + validate func(string) + }{ + { + name: "original image", + width: "", height: "", + validate: func(path string) { + s.Equal(s.testImagePath, path) + }, + }, + { + name: "resized image", + width: "50", height: "50", + validate: func(path string) { + s.Contains(path, "w50_h50") + _, err := os.Stat(path) + s.NoError(err) + }, + }, + { + name: "fit image", + width: "50", height: "50", fit: "1", + validate: func(path string) { + s.Contains(path, "w50_h50_fit") + _, err := os.Stat(path) + s.NoError(err) + }, + }, + { + name: "fill image", + width: "50", height: "50", fill: "center", + validate: func(path string) { + s.Contains(path, "w50_h50_fill-center") + _, err := os.Stat(path) + s.NoError(err) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + iv, err := commons.NewImageVersion(tt.width, tt.height, tt.fit, tt.fill, s.testImagePath) + s.Require().NoError(err) + + path, err := iv.Get() + s.Require().NoError(err) + tt.validate(path) + }) + } +} + +func TestImageVersionTestSuite(t *testing.T) { + suite.Run(t, new(ImageVersionTestSuite)) +} + +func cleanUpGeneratedFiles() { + // Clean up any generated files + matches, err := filepath.Glob(testImagePattern) + if err != nil { + panic(err) + } + + for _, match := range matches { + err := os.Remove(match) + if err != nil { + panic(err) + } + } +} diff --git a/commons/controller.go b/commons/interfaces.go similarity index 76% rename from commons/controller.go rename to commons/interfaces.go index ea045f9e851a20cb99973898091bc0b03ecef139..6deaa787ddb7e46e7d92a9f7f665285c1b662a86 100644 --- a/commons/controller.go +++ b/commons/interfaces.go @@ -1,4 +1,3 @@ -// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net> // SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> // // SPDX-License-Identifier: AGPL-3.0-only @@ -10,3 +9,7 @@ import "github.com/labstack/echo/v4" type Controller interface { SetupRoutes(e *echo.Echo) } + +type Middleware interface { + Register(e *echo.Echo) +} diff --git a/commons/layout_func.go b/commons/layout_func.go deleted file mode 100644 index 61ddd7e140bb1cc29c6ebb0c8bce6947c398261c..0000000000000000000000000000000000000000 --- a/commons/layout_func.go +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net> -// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> -// -// SPDX-License-Identifier: AGPL-3.0-only - -package commons - -import ( - "github.com/labstack/echo/v4" - g "maragu.dev/gomponents" -) - -type LayoutFunc func(ctx echo.Context, metadata ViewLayoutMetadata, children ...g.Node) g.Node diff --git a/commons/mailer.go b/commons/mailer.go new file mode 100644 index 0000000000000000000000000000000000000000..2b89239ed1dcffe211e772822596ea0354fc59db --- /dev/null +++ b/commons/mailer.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> +// +// SPDX-License-Identifier: AGPL-3.0-only + +package commons + +import ( + "crypto/tls" + "sync" + + "go.uber.org/fx" + "gopkg.in/gomail.v2" + "kita.gawa.moe/paweljw/vellum/config" +) + +type IDialer interface { + DialAndSend(...*gomail.Message) error +} + +type Mailer struct { + Config *config.Config + Dialer IDialer + + mutex sync.Mutex +} + +func (m *Mailer) SendEmailAsync(to, subject, body string) { + go func() { + m.mutex.Lock() + _ = m.SendEmail(to, subject, body) + m.mutex.Unlock() + }() +} + +func (m *Mailer) SendEmail(to, subject, body string) error { + message := gomail.NewMessage() + + // Set email headers + message.SetHeader("From", m.Config.EmailFrom) + message.SetHeader("To", to) + message.SetHeader("Subject", subject) + + // Set email body + message.SetBody("text/plain", body) + + // Send the email + return m.Dialer.DialAndSend(message) +} + +type NewMailerParams struct { + fx.In + + Config *config.Config +} + +type NewMailerResult struct { + fx.Out + + Mailer *Mailer +} + +func NewMailer(p NewMailerParams) NewMailerResult { + dialer := gomail.NewDialer(p.Config.EmailHost, p.Config.EmailPort, p.Config.EmailUser, p.Config.EmailPass) + dialer.TLSConfig = &tls.Config{InsecureSkipVerify: true} + + return NewMailerResult{ + Mailer: &Mailer{ + Config: p.Config, + Dialer: dialer, + + mutex: sync.Mutex{}, + }, + } +} diff --git a/commons/mailer_test.go b/commons/mailer_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0b250f99bdb91d4acd0bc1083eca9ede6427aa0a --- /dev/null +++ b/commons/mailer_test.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> +// +// SPDX-License-Identifier: AGPL-3.0-only + +package commons_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "gopkg.in/gomail.v2" + "kita.gawa.moe/paweljw/vellum/commons" + "kita.gawa.moe/paweljw/vellum/config" +) + +type MockDialer struct { + mock.Mock +} + +func (m *MockDialer) DialAndSend(msg ...*gomail.Message) error { + args := m.Called(msg[0]) + return args.Error(0) +} + +func TestMailerSendEmail(t *testing.T) { + cfg := &config.Config{ + EmailFrom: "test@example.local", + } + + mockDialer := new(MockDialer) + + mockDialer.On("DialAndSend", mock.MatchedBy(func(m *gomail.Message) bool { + return m.GetHeader("From")[0] == "test@example.local" && + m.GetHeader("To")[0] == "recipient@example.local" && + m.GetHeader("Subject")[0] == "Test Subject" + })).Return(nil) + + mailer := commons.Mailer{ + Config: cfg, + Dialer: mockDialer, + } + + err := mailer.SendEmail("recipient@example.local", "Test Subject", "Test Body") + assert.NoError(t, err) + mockDialer.AssertExpectations(t) +} + +func TestMailerSendEmailAsync(t *testing.T) { + mockDialer := new(MockDialer) + cfg := &config.Config{ + EmailFrom: "test@example.local", + } + + mailer := commons.Mailer{ + Config: cfg, + Dialer: mockDialer, + } + + mockDialer.On("DialAndSend", mock.MatchedBy(func(m *gomail.Message) bool { + return m.GetHeader("From")[0] == "test@example.local" && + m.GetHeader("To")[0] == "recipient@example.local" && + m.GetHeader("Subject")[0] == "Test Subject" + })).Return(nil) + + mailer.SendEmailAsync("recipient@example.local", "Test Subject", "Test Body") + + // Give the goroutine time to complete + time.Sleep(100 * time.Millisecond) + mockDialer.AssertExpectations(t) +} + +func TestNewMailer(t *testing.T) { + cfg := &config.Config{ + EmailHost: "smtp.example.local", + EmailPort: 587, + EmailUser: "user", + EmailPass: "pass", + EmailFrom: "test@example.local", + } + + result := commons.NewMailer(commons.NewMailerParams{Config: cfg}) + + assert.Equal(t, cfg, result.Mailer.Config) + assert.NotNil(t, result.Mailer.Dialer) +} diff --git a/commons/markdown_test.go b/commons/markdown_test.go index d65a2353bdca8c53758b37891a0a2df6e22426e3..10c4910aaf391514ecb03e47fab62f29de42f968 100644 --- a/commons/markdown_test.go +++ b/commons/markdown_test.go @@ -36,3 +36,126 @@ func TestUntrustedMarkdownToHTML_Formatting(t *testing.T) { html := commons.UntrustedMarkdownToHTML(md) assert.Equal(t, "<p><strong>hello</strong></p>\n", html) } + +func TestExtractFrontmatter(t *testing.T) { + // Given + md := `--- +toc: true +subdocuments_disabled: true +redirect: /test +description: This is a test description +comments_disabled: true +--- +# Content here` + + // When + frontmatter, content, exists := commons.ExtractFrontMatter(md) + + // Then + assert.True(t, exists) + assert.Equal(t, "This is a test description", frontmatter.Description) + assert.Equal(t, true, frontmatter.Toc) + assert.Equal(t, true, frontmatter.IsSubdocumentsDisabled) + assert.Equal(t, "/test", frontmatter.Redirect) + assert.Equal(t, true, frontmatter.IsCommentsDisabled) + assert.Equal(t, "# Content here", content) +} + +func TestExtractFrontmatter_NoFrontmatter(t *testing.T) { + // Given + md := "# Just content\nNo frontmatter here" + + // When + frontmatter, content, exists := commons.ExtractFrontMatter(md) + + // Then + assert.Equal(t, commons.DocumentFrontMatter{}, frontmatter) + assert.Equal(t, "# Just content\nNo frontmatter here", content) + assert.False(t, exists) +} + +func TestExtractDescriptionFromFrontmatter(t *testing.T) { + // Given + frontmatter := `This is a test description +<!--more--> +Additional content here` + + // When + description := commons.ExtractDescription(frontmatter) + + // Then + assert.Equal(t, "This is a test description", description) +} + +func TestExtractDescription_NoDescription(t *testing.T) { + // Given + frontmatter := `Given that there is no "more" tag, the description will contain the first 159 characters of the content plus an ellipsis, deleting tags such as + <html>injects</html>, latest posts {{latest:feed:1}}, and gallery {{gallery:some-gallery}}` + + // When + description := commons.ExtractDescription(frontmatter) + + // Then + assert.Equal(t, "Given that there is no \"more\" tag, the description will contain the first 159 characters of the content plus an ellipsis, deleting tags such as\n\tinjects, lates…", description) +} + +func TestMarkdownToHTML(t *testing.T) { + // Given + md := `# Hello, World! +This is a test post. + +## Subheading + +This is a subheading. + +### Subsubheading + +This is a subsubheading. + +It also contains a [link](https://example.com) and an <div>element</div>. +` + + // When + html := commons.MarkdownToHTML(md, commons.MarkdownRenderOpts{ + Toc: true, + }) + + // Then + assert.Contains(t, html, "<h1 id=\"hello-world\">Hello, World!</h1>") + assert.Contains(t, html, "<h2 id=\"subheading\">Subheading</h2>") + assert.Contains(t, html, "<h3 id=\"subsubheading\">Subsubheading</h3>") + assert.Contains(t, html, "<a href=\"https://example.com\" target=\"_blank\" rel=\"noreferrer\">link</a>") + assert.Contains(t, html, "<div>element</div>") + assert.Contains(t, html, "<nav>") + assert.Contains(t, html, "<a href=\"#hello-world\">Hello, World!</a>") + assert.Contains(t, html, "<a href=\"#subheading\">Subheading</a>") + assert.Contains(t, html, "<a href=\"#subsubheading\">Subsubheading</a>") +} + +func TestMarkdownToHTML_NoToc(t *testing.T) { + // Given + md := `# Hello, World! +This is a test post. + +## Subheading + +This is a subheading. + +### Subsubheading + +It also contains a [link](https://example.com) and an <div>element</div>. +` + + // When + html := commons.MarkdownToHTML(md, commons.MarkdownRenderOpts{ + Toc: false, + }) + + // Then + assert.Contains(t, html, "<h1 id=\"hello-world\">Hello, World!</h1>") + assert.Contains(t, html, "<h2 id=\"subheading\">Subheading</h2>") + assert.Contains(t, html, "<h3 id=\"subsubheading\">Subsubheading</h3>") + assert.Contains(t, html, "<a href=\"https://example.com\" target=\"_blank\" rel=\"noreferrer\">link</a>") + assert.Contains(t, html, "<div>element</div>") + assert.NotContains(t, html, "<nav>") +} diff --git a/commons/middleware.go b/commons/middleware.go deleted file mode 100644 index 425caa3f374b13329134a4988ac1e19f2bdaa413..0000000000000000000000000000000000000000 --- a/commons/middleware.go +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net> -// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> -// -// SPDX-License-Identifier: AGPL-3.0-only - -package commons - -import "github.com/labstack/echo/v4" - -type Middleware interface { - Register(e *echo.Echo) -} diff --git a/commons/module.go b/commons/module.go index 10f817317b5cde412f5096e508a17cf6a8f5c3f9..356255bd2e4a44013b34763b21a80821c2cc1cf1 100644 --- a/commons/module.go +++ b/commons/module.go @@ -7,4 +7,9 @@ package commons import "go.uber.org/fx" -var Module = fx.Module("commons", fx.Provide(NewViewRenderer)) +var Module = fx.Module("commons", fx.Provide( + NewViewRenderer, + NewMailer, + NewFlasher, + NewHCaptchaVerifier, +)) diff --git a/commons/og_image_test.go b/commons/og_image_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7bb5fe4773aa03d8f1841ce570c40ffe2d0ff481 --- /dev/null +++ b/commons/og_image_test.go @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> +// +// SPDX-License-Identifier: AGPL-3.0-only + +package commons_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" + "kita.gawa.moe/paweljw/vellum/commons" +) + +type OGImageTestSuite struct { + suite.Suite +} + +func (s *OGImageTestSuite) TestGenerateOGImage() { + // Given + title := "Test Title" + + // When + buffer, err := commons.GenerateOGImage(title) + + // Then + s.Require().NoError(err) + s.NotNil(buffer) + + fixtureDir := "fixtures" + err = os.MkdirAll(fixtureDir, 0755) + s.Require().NoError(err) + + fixturePath := filepath.Join(fixtureDir, "og_image_reference.png") + + expected, err := os.ReadFile(fixturePath) + s.Require().NoError(err) + + if !s.Equal(expected, buffer.Bytes()) { + comparisonPath := filepath.Join(fixtureDir, "og_image_comparison.png") + err = os.WriteFile(comparisonPath, buffer.Bytes(), 0644) + s.Require().NoError(err) + } +} + +func TestOGImageTestSuite(t *testing.T) { + suite.Run(t, new(OGImageTestSuite)) +} diff --git a/commons/validator_test.go b/commons/validator_test.go new file mode 100644 index 0000000000000000000000000000000000000000..abc54a459d4b3158e4da24b4978d52aaad8cec02 --- /dev/null +++ b/commons/validator_test.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> +// +// SPDX-License-Identifier: AGPL-3.0-only + +package commons_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "kita.gawa.moe/paweljw/vellum/commons" +) + +type testForm struct { + Name string `validate:"required,min=3"` +} + +type ValidatorTestSuite struct { + suite.Suite + sut *commons.Validator +} + +func (s *ValidatorTestSuite) SetupTest() { + s.sut = commons.NewValidator() +} + +func (s *ValidatorTestSuite) TestValidateWithValidData() { + form := testForm{ + Name: "Test Name", + } + + result := s.sut.Validate(form) + + s.True(result) + s.Nil(s.sut.Error) +} + +func (s *ValidatorTestSuite) TestValidateWithInvalidData() { + form := testForm{ + Name: "Te", // Too short, minimum 3 characters + } + + result := s.sut.Validate(form) + + s.False(result) + s.NotNil(s.sut.Error) +} + +func (s *ValidatorTestSuite) TestMessagesWithValidData() { + form := testForm{ + Name: "Test Name", + } + + _ = s.sut.Validate(form) + + messages := s.sut.Messages("Name") + + s.Equal("", messages) +} + +func (s *ValidatorTestSuite) TestMessagesWithInvalidData() { + form := testForm{ + Name: "Te", // Too short, minimum 3 characters + } + + _ = s.sut.Validate(form) + + messages := s.sut.Messages("Name") + + s.Equal("Name must be at least 3 characters in length", messages) +} + +func TestValidatorTestSuite(t *testing.T) { + suite.Run(t, new(ValidatorTestSuite)) +} diff --git a/commons/view_func.go b/commons/view_func.go deleted file mode 100644 index f79f07c367fc69a265e83cf6815315a0ab242483..0000000000000000000000000000000000000000 --- a/commons/view_func.go +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net> -// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> -// -// SPDX-License-Identifier: AGPL-3.0-only - -package commons - -import ( - "github.com/labstack/echo/v4" - g "maragu.dev/gomponents" -) - -type ViewFunc func(ctx echo.Context) g.Node diff --git a/commons/view_layout_metadata.go b/commons/view_layout_metadata.go deleted file mode 100644 index 119dfb4d27aee5f9e7a7f1695dbd4ecb83629ab3..0000000000000000000000000000000000000000 --- a/commons/view_layout_metadata.go +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net> -// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> -// -// SPDX-License-Identifier: AGPL-3.0-only - -package commons - -type ViewLayoutMetadata struct { - Title string - Description string - FlashSuccess string - FlashError string - ImageUrl string - ThemeColor string - Host string -} diff --git a/commons/view_renderer.go b/commons/view_renderer.go index 80dde3cdc1a70c0d7ca6ed042ee31489a058422c..3371a048984ea6719f16b15cfbbd80d01f38cb0b 100644 --- a/commons/view_renderer.go +++ b/commons/view_renderer.go @@ -8,23 +8,38 @@ package commons import ( "bytes" - "github.com/alexedwards/scs/v2" "github.com/labstack/echo/v4" "go.uber.org/fx" "go.uber.org/zap" - "kita.gawa.moe/paweljw/vellum/config" g "maragu.dev/gomponents" ) type ViewRenderer struct { - Logger *zap.Logger - SessionManager *scs.SessionManager - LayoutFunc LayoutFunc + Logger *zap.Logger + LayoutFunc LayoutFunc + Flasher iFlasher +} + +type ViewLayoutMetadata struct { + Title string + Description string + FlashSuccess string + FlashError string + ImageUrl string + ThemeColor string + Host string +} + +type ViewFunc func(ctx echo.Context) g.Node + +type LayoutFunc func(ctx echo.Context, metadata ViewLayoutMetadata, children ...g.Node) g.Node + +type iFlasher interface { + GetFlashes(ctx IContext) (string, string) } func (r *ViewRenderer) renderWithLayout(ctx echo.Context, metadata ViewLayoutMetadata, layout LayoutFunc, f ViewFunc) (string, error) { - metadata.FlashSuccess = r.SessionManager.PopString(ctx.Request().Context(), config.FLASH_SUCCESS_KEY) - metadata.FlashError = r.SessionManager.PopString(ctx.Request().Context(), config.FLASH_ERROR_KEY) + metadata.FlashSuccess, metadata.FlashError = r.Flasher.GetFlashes(ctx) return renderToString(layout(ctx, metadata, f(ctx))) } @@ -35,8 +50,8 @@ func (r *ViewRenderer) Render(ctx echo.Context, metadata ViewLayoutMetadata, f V type NewViewRendererParams struct { fx.In - Logger *zap.Logger - SessionManager *scs.SessionManager + Logger *zap.Logger + Flasher *Flasher } type NewViewRendererResult struct { @@ -47,8 +62,8 @@ type NewViewRendererResult struct { func NewViewRenderer(p NewViewRendererParams) NewViewRendererResult { return NewViewRendererResult{ ViewRenderer: &ViewRenderer{ - Logger: p.Logger, - SessionManager: p.SessionManager, + Logger: p.Logger, + Flasher: p.Flasher, }, } } diff --git a/commons/view_renderer_test.go b/commons/view_renderer_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2d93cd469815476ccabb97fcc4fc446251178cbb --- /dev/null +++ b/commons/view_renderer_test.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net> +// +// SPDX-License-Identifier: AGPL-3.0-only + +package commons_test + +import ( + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "go.uber.org/zap" + "kita.gawa.moe/paweljw/vellum/commons" + g "maragu.dev/gomponents" +) + +type MockFlasher struct { + mock.Mock +} + +func (m *MockFlasher) GetFlashes(ctx commons.IContext) (string, string) { + return "success message", "error message" +} + +func TestViewRenderer_Render(t *testing.T) { + mockFlasher := &MockFlasher{} + + renderer := &commons.ViewRenderer{ + Logger: zap.NewNop(), + LayoutFunc: func(ctx echo.Context, metadata commons.ViewLayoutMetadata, children ...g.Node) g.Node { + assert.Equal(t, "success message", metadata.FlashSuccess) + assert.Equal(t, "error message", metadata.FlashError) + return children[0] + }, + Flasher: mockFlasher, + } + + ctx := echo.New().NewContext(nil, nil) + viewFunc := func(ctx echo.Context) g.Node { + return g.Text("test content") + } + + result, err := renderer.Render(ctx, commons.ViewLayoutMetadata{}, viewFunc) + + assert.NoError(t, err) + assert.Equal(t, "test content", result) +} diff --git a/frontend/comments_controller.go b/frontend/comments_controller.go index 15ffc7b50dc1827c469fc9e29dced91f65d49322..cc7ffacf5b233740f467ee6cd9ca4ac4810f5ac3 100644 --- a/frontend/comments_controller.go +++ b/frontend/comments_controller.go @@ -8,7 +8,6 @@ import ( "fmt" "net/http" - "github.com/alexedwards/scs/v2" "github.com/labstack/echo/v4" "go.uber.org/fx" "go.uber.org/zap" @@ -19,11 +18,13 @@ import ( ) type CommentsController struct { - CommentRepo *repos.CommentRepo - DocumentRepo *repos.DocumentRepo - Config *config.Config - SessionManager *scs.SessionManager - Logger *zap.Logger + CommentRepo *repos.CommentRepo + DocumentRepo *repos.DocumentRepo + Config *config.Config + Logger *zap.Logger + Mailer *commons.Mailer + Flasher *commons.Flasher + HCaptchaVerifier *commons.HCaptchaVerifier } func (c *CommentsController) SetupRoutes(e *echo.Echo) { @@ -36,17 +37,17 @@ func (c *CommentsController) create(ctx echo.Context) error { doc := models.Document{} err := c.DocumentRepo.GetByPath(path, &doc) if err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to find document") + c.Flasher.FlashError(ctx, "Failed to find document") return ctx.Redirect(http.StatusFound, "/") } token := ctx.FormValue("h-captcha-response") - err = commons.VerifyHCaptcha(token, ctx.RealIP(), c.Config) + err = c.HCaptchaVerifier.Verify(token, ctx.RealIP()) if err != nil { c.Logger.Error("Failed to verify captcha", zap.Error(err)) - commons.FlashError(c.SessionManager, ctx, "Failed to verify captcha") + c.Flasher.FlashError(ctx, "Failed to verify captcha") return ctx.Redirect(http.StatusFound, path) } @@ -58,13 +59,12 @@ func (c *CommentsController) create(ctx echo.Context) error { err = c.CommentRepo.Create(&comment) if err != nil { - commons.FlashError(c.SessionManager, ctx, "Failed to create comment") + c.Flasher.FlashError(ctx, "Failed to create comment") return ctx.Redirect(http.StatusFound, path) } if ctx.Get("user") == nil { // Do not inform the owner that they made a comment themselves - go commons.SendEmail( - c.Config, + c.Mailer.SendEmailAsync( c.Config.OwnerEmail, fmt.Sprintf("%s commented on %s", comment.Author, doc.Title), fmt.Sprintf("See their comment at %s%s#comment-%d", c.Config.HttpHost, path, comment.ID), @@ -76,11 +76,13 @@ func (c *CommentsController) create(ctx echo.Context) error { type NewCommentsControllerParams struct { fx.In - CommentRepo *repos.CommentRepo - DocumentRepo *repos.DocumentRepo - Config *config.Config - SessionManager *scs.SessionManager - Logger *zap.Logger + CommentRepo *repos.CommentRepo + DocumentRepo *repos.DocumentRepo + Config *config.Config + Logger *zap.Logger + Mailer *commons.Mailer + Flasher *commons.Flasher + HCaptchaVerifier *commons.HCaptchaVerifier } type NewCommentsControllerResult struct { @@ -91,11 +93,13 @@ type NewCommentsControllerResult struct { func NewCommentsController(p NewCommentsControllerParams) NewCommentsControllerResult { return NewCommentsControllerResult{ CommentsController: &CommentsController{ - CommentRepo: p.CommentRepo, - DocumentRepo: p.DocumentRepo, - Config: p.Config, - SessionManager: p.SessionManager, - Logger: p.Logger, + CommentRepo: p.CommentRepo, + DocumentRepo: p.DocumentRepo, + Config: p.Config, + Logger: p.Logger, + Mailer: p.Mailer, + Flasher: p.Flasher, + HCaptchaVerifier: p.HCaptchaVerifier, }, } } diff --git a/frontend/components/page_header.go b/frontend/components/page_header.go index 310da9f3de4106f47d8b099a0eccf3a4712fa3bb..167c5f1fbb18d657563feb987b5eb88099295bb5 100644 --- a/frontend/components/page_header.go +++ b/frontend/components/page_header.go @@ -6,37 +6,31 @@ package components import ( - "fmt" - - "kita.gawa.moe/paweljw/vellum/commons" c "kita.gawa.moe/paweljw/vellum/commons/components" g "maragu.dev/gomponents" h "maragu.dev/gomponents/html" ) func PageHeader(title string, children ...g.Node) g.Node { - color, endColor := ColorPair(title) - return HeaderWithColor(title, color, endColor, c.Null(), children...) + return BaseHeader(title, c.Null(), children...) } func PageHeaderWithExtraNav(title string, extraNav g.Node, children ...g.Node) g.Node { - color, endColor := ColorPair(title) - return HeaderWithColor(title, color, endColor, extraNav, children...) + return BaseHeader(title, extraNav, children...) } -func HeaderWithColor(title string, color string, endColor string, extraNav g.Node, children ...g.Node) g.Node { +func BaseHeader(title string, extraNav g.Node, children ...g.Node) g.Node { return c.Group( h.Div( h.Class("flex flex-col justify-between pb-4 gap-4 ml-[-1rem] mr-[-1rem] p-4 bg-neutral-100"), - h.Title(fmt.Sprintf("Oh this color? That's %s, thank you for asking.", color)), - h.H1( - g.Text(title), - h.Style(fmt.Sprintf("background-image: linear-gradient(to right, %s 0%%, %s 98%%); display: table !important", color, endColor)), - h.Class("bg-clip-text text-transparent text-6xl font-bold wrap text-center md:text-left drop-shadow-md"), + h.Div( + h.H1(g.Text(title), h.Class("text-6xl drop-shadow-lg")), ), g.If(len(children) > 0, g.Group(children)), ), - h.Nav(h.Class("z-40 sticky top-0 bg-white bg-opacity-70 backdrop-blur-md flex flex-row flex-wrap max-w-screen justify-around md:justify-start gap-4 mb-3 p-4 py-2 ml-[-1rem] mr-[-1rem] border-b border-gray-200"), + h.Nav( + h.ID("header-nav"), + h.Class("z-40 sticky top-0 bg-white bg-opacity-70 backdrop-blur-md flex flex-row flex-wrap max-w-screen justify-around md:justify-start gap-4 mb-3 p-4 py-2 ml-[-1rem] mr-[-1rem] border-b border-gray-200"), h.P(h.Class("flex hidden md:flex"), g.Text("Jump to:")), h.A(g.Text("home"), h.Href("/"), h.Class("flex")), h.A(g.Text("archives"), h.Href(c.PublicArchivesRootLink()), h.Class("flex")), @@ -47,9 +41,3 @@ func HeaderWithColor(title string, color string, endColor string, extraNav g.Nod ), ) } - -func ColorPair(title string) (string, string) { - color := commons.RepresentativeColorHSV(title, 80, 75) - endColor := commons.RepresentativeColorHSV(title, 50, 55) - return color, endColor -} diff --git a/frontend/document_view.go b/frontend/document_view.go index 92e289889fc91356354936f1235b6a13a807dc0a..f570547e0066c63fa8cf255c51188ab3cb45f59c 100644 --- a/frontend/document_view.go +++ b/frontend/document_view.go @@ -32,13 +32,11 @@ type DocumentViewShowParams struct { func (v *DocumentView) Show(ctx echo.Context, data DocumentViewShowParams) string { isAuthed := ctx.Get("user") != nil - color := commons.RepresentativeColorHSV(data.Document.Title, 12, 95) ogImageUrl := commons.JoinPath([]string{v.Config.HttpHost, "!og", data.Document.BakedPath + ".png"}) return v.render(ctx, commons.ViewLayoutMetadata{ Title: data.Document.Title, - ThemeColor: color, Description: data.Document.ShortDescription(), ImageUrl: ogImageUrl, }, func(ctx echo.Context) g.Node { @@ -65,7 +63,7 @@ func (v *DocumentView) Show(ctx echo.Context, data DocumentViewShowParams) strin g.If(isAuthed, cc.Group( g.Text(" | "), - h.A(g.Text("edit"), h.Href(cc.EditDocumentLink(data.Document)), h.Target("_blank"), h.Class("text-blue-500 font-bold")), + h.A(g.Text("edit"), h.Href(cc.EditDocumentLink(data.Document)), h.Target("_blank"), h.Class("font-bold")), ), ), ), @@ -95,17 +93,13 @@ type DocumentViewNotFoundParams struct { } func (v *DocumentView) NotFound(ctx echo.Context, data DocumentViewNotFoundParams) string { - color, endColor := c.ColorPair("Not found :sadge:") return v.render(ctx, commons.ViewLayoutMetadata{ Title: "Not found", - ThemeColor: color, Description: "The page was not found.", }, func(ctx echo.Context) g.Node { return cc.Group( - c.HeaderWithColor( + c.BaseHeader( "Not found", - color, - endColor, cc.Null(), ), h.Div( diff --git a/frontend/documents_controller.go b/frontend/documents_controller.go index 692e4f9c7f94e159073ed581d872c9cbb166a03f..a28801dfd73160f23294eae2646318b2ddf10371 100644 --- a/frontend/documents_controller.go +++ b/frontend/documents_controller.go @@ -74,8 +74,8 @@ func (h *DocumentsController) view(ctx echo.Context) error { } doc := models.Document{} - err := h.DocumentRepo.GetByPath(path, &doc) - if err != nil || (doc.Status == models.DocumentStatusPrivate && ctx.Get("user") == nil) { + err := h.DocumentRepo.GetPublicByPath(path, &doc) + if err != nil { return ctx.HTML(404, h.View.NotFound(ctx, DocumentViewNotFoundParams{Path: path})) } diff --git a/frontend/media_controller.go b/frontend/media_controller.go index 654f69f1037dab2210276efee040278b5686cfb5..eef28546b86b222cb28a879475b70e929a9d4530 100644 --- a/frontend/media_controller.go +++ b/frontend/media_controller.go @@ -36,7 +36,11 @@ func (c *MediaController) show(ctx echo.Context) error { return ctx.String(http.StatusNotFound, "Medium not found: "+err.Error()) } - imageOptions, err := commons.NewImageOptions( + if !medium.IsRenderable() { + return ctx.Attachment(fmt.Sprintf("%s/%s", c.Config.StoragePath, medium.StoragePath), medium.Filename) + } + + imageVersion, err := commons.NewImageVersion( ctx.QueryParam("width"), ctx.QueryParam("height"), ctx.QueryParam("fit"), @@ -48,26 +52,18 @@ func (c *MediaController) show(ctx echo.Context) error { return ctx.String(http.StatusBadRequest, "Invalid image options: "+err.Error()) } - if imageOptions.VersionDoesntExist() { + if !imageVersion.Exists() { referer := ctx.Request().Referer() if referer == "" || !strings.HasPrefix(referer, c.Config.HttpHost) { return ctx.String(http.StatusForbidden, "clever girl") } } - if err != nil { - return ctx.String(http.StatusBadRequest, "Invalid image options: "+err.Error()) - } - - fullPath, err := imageOptions.Apply() + fullPath, err := imageVersion.Get() if err != nil { return ctx.String(http.StatusInternalServerError, "Error processing image: "+err.Error()) } - if !medium.IsRenderable() || ctx.QueryParam("download") != "" { - return ctx.Attachment(fullPath, medium.Filename) - } - etag := fmt.Sprintf("%x", md5.Sum([]byte(fullPath))) ctx.Response().Header().Set("Etag", etag) ctx.Response().Header().Set("Cache-Control", "public, max-age=0, must-revalidate") @@ -77,6 +73,10 @@ func (c *MediaController) show(ctx echo.Context) error { } } + if ctx.QueryParam("download") != "" { + return ctx.Attachment(fullPath, medium.Filename) + } + return ctx.File(fullPath) } diff --git a/frontend/search_controller.go b/frontend/search_controller.go index d57a231f2c516fce6f62fbe392cd75f984751d28..e6310d7b07c44d9db5b68643c3d9987801b1b755 100644 --- a/frontend/search_controller.go +++ b/frontend/search_controller.go @@ -7,7 +7,6 @@ package frontend import ( "net/http" - "github.com/alexedwards/scs/v2" "github.com/labstack/echo/v4" "go.uber.org/fx" "kita.gawa.moe/paweljw/vellum/commons" @@ -18,7 +17,7 @@ import ( type SearchController struct { DocumentRepo *repos.DocumentRepo View *SearchView - Session *scs.SessionManager + Flasher *commons.Flasher } func (c SearchController) SetupRoutes(ctx *echo.Echo) { @@ -29,7 +28,7 @@ func (c *SearchController) search(ctx echo.Context) error { query := ctx.QueryParam("query") documents, err := c.DocumentRepo.SearchPublic(query) if err != nil { - commons.FlashError(c.Session, ctx, "Failed to search documents") + c.Flasher.FlashError(ctx, "Failed to search documents") return ctx.Redirect(http.StatusSeeOther, "/") } return ctx.HTML(http.StatusOK, c.View.Index(ctx, SearchViewIndexParams{ @@ -42,7 +41,7 @@ type NewSearchControllerParams struct { fx.In DocumentRepo *repos.DocumentRepo View *SearchView - Session *scs.SessionManager + Flasher *commons.Flasher } type NewSearchControllerResult struct { @@ -55,7 +54,7 @@ func NewSearchController(params NewSearchControllerParams) NewSearchControllerRe Controller: SearchController{ DocumentRepo: params.DocumentRepo, View: params.View, - Session: params.Session, + Flasher: params.Flasher, }, } } diff --git a/frontend/sessions_controller.go b/frontend/sessions_controller.go index bb3010fafd60bdaa95632f758188d827bb3a6e61..67fac4088589725c7ec5060a54d1ff409839257f 100644 --- a/frontend/sessions_controller.go +++ b/frontend/sessions_controller.go @@ -24,6 +24,8 @@ type SessionsController struct { Config *config.Config SessionManager *scs.SessionManager View *SessionView + Mailer *commons.Mailer + Flasher *commons.Flasher } func (c SessionsController) SetupRoutes(e *echo.Echo) { @@ -49,14 +51,13 @@ func (c *SessionsController) create(ctx echo.Context) error { return ctx.String(http.StatusInternalServerError, "Failed to create session token") } - go commons.SendEmail( - c.Config, + c.Mailer.SendEmailAsync( c.Config.OwnerEmail, "Vellum Session Token", fmt.Sprintf("Start your session at %s%s/start/%s", c.Config.HttpHost, config.SESSIONS_CONTROLLER_ROOT, token), ) - commons.FlashSuccess(c.SessionManager, ctx, "Session token queued to be sent.") + c.Flasher.FlashSuccess(ctx, "Session token queued to be sent.") return ctx.Redirect(http.StatusFound, "/") } @@ -70,13 +71,13 @@ func (c *SessionsController) start(ctx echo.Context) error { } c.SessionManager.Put(ctx.Request().Context(), config.SESSION_TOKEN_KEY, token) - commons.FlashSuccess(c.SessionManager, ctx, "Welcome back!") + c.Flasher.FlashSuccess(ctx, "Welcome back!") return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT) } func (c *SessionsController) delete(ctx echo.Context) error { c.SessionManager.Remove(ctx.Request().Context(), config.SESSION_TOKEN_KEY) - commons.FlashSuccess(c.SessionManager, ctx, "Goodbye!") + c.Flasher.FlashSuccess(ctx, "Goodbye!") return ctx.Redirect(http.StatusFound, "/") } @@ -87,6 +88,7 @@ type NewSessionsControllerParams struct { Config *config.Config SessionManager *scs.SessionManager View *SessionView + Mailer *commons.Mailer } type NewSessionsControllerResult struct { @@ -102,6 +104,7 @@ func NewSessionsController(p NewSessionsControllerParams) NewSessionsControllerR Config: p.Config, SessionManager: p.SessionManager, View: p.View, + Mailer: p.Mailer, }, } } diff --git a/frontend/tag_view.go b/frontend/tag_view.go index aa551bd9c0f25fba3bf36b3c7e3cd4115f2dbb60..2bcc4a228d662071926949ddda0e2a921eeaa4f5 100644 --- a/frontend/tag_view.go +++ b/frontend/tag_view.go @@ -27,17 +27,13 @@ type TagViewShowParams struct { } func (v *TagView) Show(ctx echo.Context, params TagViewShowParams) string { - color, endColor := c.ColorPair(params.Tag.String()) return v.render(ctx, commons.ViewLayoutMetadata{ - Title: "Tag: " + params.Tag.String(), - ThemeColor: color, + Title: "Tag: " + params.Tag.String(), }, func(ctx echo.Context) g.Node { return cc.Group( - c.HeaderWithColor( + c.BaseHeader( "Tag: "+params.Tag.String(), - color, - endColor, h.A(g.Text("up ⤴"), h.Href(cc.PublicTagsRootLink()), h.Class("flex")), ), c.DocumentListingWithoutHeader(params.Tag.Documents), @@ -51,14 +47,12 @@ type TagViewIndexParams struct { func (v *TagView) Index(ctx echo.Context, params TagViewIndexParams) string { title := "Tags" - color, endColor := c.ColorPair(title) return v.render(ctx, commons.ViewLayoutMetadata{ - Title: title, - ThemeColor: color, + Title: title, }, func(ctx echo.Context) g.Node { return cc.Group( - c.HeaderWithColor(title, color, endColor, h.A(g.Text("up ⤴"), h.Href("/"), h.Class("flex"))), + c.BaseHeader(title, h.A(g.Text("up ⤴"), h.Href("/"), h.Class("flex"))), h.Ul( h.Class("list-none"), g.Map(params.Tags, func(tag models.Tag) g.Node { diff --git a/go.mod b/go.mod index 402f60901d0a6c0425ebc2c3e647a305d68dd96b..7d931e2fa1f84afc7518578d82223e5ce7d3f0f1 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.31.0 // indirect diff --git a/go.sum b/go.sum index 7b0073429620c2c64480f362ba96bc867476bcf9..78c0bf611eb211d231dc1ae86bbc58124292562a 100644 --- a/go.sum +++ b/go.sum @@ -175,6 +175,8 @@ github.com/spazzymoto/echo-scs-session v1.0.0/go.mod h1:wd6nyO726b2b1+w+IBHYEG5v github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/src/backend.js b/src/backend.js index 319c75e1562dd5556ead7629ac274afe9823d744..9d85a996e3adf3081a7bab3067d91ba42963a142 100644 --- a/src/backend.js +++ b/src/backend.js @@ -11,7 +11,7 @@ import { initForms } from './forms'; import { markJsLoaded } from './mark_js_loaded'; import { loadMathJax } from './mathjax'; import { initSearch } from './search'; -import { smoothScroll } from './smooth_scroll'; +import { smoothScroll, setupHeaderClicks } from './smooth_scroll'; import { initSlugHandling } from './slug_handling'; import { initDangerConfirm } from './danger_confirm'; @@ -28,6 +28,7 @@ const appInit = function () { initSlugHandling(); initTagify(); initDangerConfirm(); + setupHeaderClicks(); } if (document.readyState !== 'loading') { diff --git a/src/frontend.js b/src/frontend.js index 1f8dd74c82f7f809732345df7a8e1ece5f23727b..5a0a2d9ca07c12f94bba8efaffcb33b6f3dadab2 100644 --- a/src/frontend.js +++ b/src/frontend.js @@ -8,7 +8,7 @@ import { initFlashes } from "./flashes"; import { initForms } from "./forms"; import { markJsLoaded } from "./mark_js_loaded"; import { loadMathJax } from "./mathjax"; -import { smoothScroll } from "./smooth_scroll"; +import { smoothScroll, setupHeaderClicks } from "./smooth_scroll"; import { initSubdocuments } from "./subdocuments"; import { initDangerConfirm } from "./danger_confirm"; @@ -20,6 +20,7 @@ function initFrontend() { markJsLoaded(); loadMathJax(); smoothScroll(); + setupHeaderClicks(); initDangerConfirm(); } diff --git a/src/smooth_scroll.js b/src/smooth_scroll.js index d1a9431cdfe463f87bb6bc381dfffab982eb4795..7d524f7ba38c4a5da8456bfc147c51f93bf405e4 100644 --- a/src/smooth_scroll.js +++ b/src/smooth_scroll.js @@ -3,6 +3,21 @@ // // SPDX-License-Identifier: AGPL-3.0-only +function scrollToElement(element) { + if (!element) { + return; + } + + var headerOffset = document.getElementById('header-nav').offsetHeight + 5; + var elementPosition = element.getBoundingClientRect().top; + var offsetPosition = elementPosition + window.scrollY - headerOffset; + + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }); +} + function smoothScroll() { // Handle smooth scrolling for anchor links document.querySelectorAll('a[href^="#"]').forEach(anchor => { @@ -11,15 +26,44 @@ function smoothScroll() { const targetId = this.getAttribute('href').slice(1); const targetElement = document.getElementById(targetId); - if (targetElement) { - targetElement.scrollIntoView({ - behavior: 'smooth' - }); - } + scrollToElement(targetElement); + + // Update the URL in the browser's address bar without triggering a page reload + window.history.replaceState(null, '', `#${targetId}`); + }); + }); +} + +function setupHeaderClicks() { + document.querySelectorAll('.rendered-markdown h1, .rendered-markdown h2, .rendered-markdown h3, .rendered-markdown h4, .rendered-markdown h5, .rendered-markdown h6').forEach(header => { + header.addEventListener('click', function (e) { + // Get the id of the header + const headerId = this.id; + if (!headerId) return; + + // Scroll to the header + scrollToElement(this); + + // Get current URL without any hash/anchor + const baseUrl = window.location.href.split('#')[0]; + + // Create the full URL with the header anchor + const fullUrl = `${baseUrl}#${headerId}`; + + // Copy to clipboard + navigator.clipboard.writeText(fullUrl).catch(err => { + console.error('Failed to copy URL to clipboard:', err); + }); + + // Update the URL in the browser's address bar without triggering a page reload + window.history.replaceState(null, '', `#${headerId}`); }); }); } + export { smoothScroll, + scrollToElement, + setupHeaderClicks, } \ No newline at end of file diff --git a/src/tailwind.css b/src/tailwind.css index cb2347ce5e79edf28d003a6e157ed8832b7a405e..428a025c5e5261bea186182be39a91f037ba1ffe 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -45,14 +45,14 @@ @tailwind components; @tailwind utilities; -body { - @apply text-lg; +html { + scrollbar-gutter: stable both-edges; } a { - @apply text-blue-700; - @apply hover:text-blue-500; - @apply hover:underline; + @apply text-pink-700; + @apply hover:text-pink-500; + @apply hover:underline hover:decoration-pink-500 hover:drop-shadow-lg; } .chromahl-chroma { @@ -62,32 +62,7 @@ a { } .rendered-markdown { - @apply flex-grow w-full overflow-x-auto; -} - -h1 { - @apply text-6xl; - @apply font-bold; -} - -h2 { - @apply text-5xl; - @apply font-bold; -} - -h3 { - @apply text-4xl; - @apply font-bold; -} - -h4 { - @apply text-3xl; - @apply font-bold; -} - -h5 { - @apply text-2xl; - @apply font-bold; + @apply flex-grow w-full overflow-x-auto break-words; } .rendered-markdown p { @@ -163,16 +138,51 @@ h2, h3, h4, h5 { - display: table; - @apply bg-gradient-to-r from-purple-500 via-violet-500 to-indigo-500 bg-clip-text text-transparent; - @apply drop-shadow-sm; + @apply bg-gradient-to-r from-pink-500 to-violet-500 bg-clip-text text-transparent; + @apply font-bold w-full drop-shadow-md hover:drop-shadow-lg; +} + +.rendered-markdown h1, +.rendered-markdown h2, +.rendered-markdown h3, +.rendered-markdown h4, +.rendered-markdown h5 { + @apply border-b border-pink-200 pb-2 mt-8 mb-6; + + &:hover { + @apply cursor-pointer; + + &::after { + content: " #"; + @apply text-pink-300 font-normal inline; + } + } +} + +h1 { + @apply text-5xl; +} + +h2 { + @apply text-4xl; +} + +h3 { + @apply text-3xl; +} + +h4 { + @apply text-2xl; +} + +h5 { + @apply text-xl; } .rendered-markdown nav+h1 { @apply hidden; } - .rendered-markdown nav { @apply shadow-md; @apply rounded-md;