diff --git a/AGENTS.md b/AGENTS.md index ad73f9b..3fbcac3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ all claims with fresh commands. ## Project Overview micrach is a small, server-rendered, single-board imageboard. It uses Go, -Fiber v2, Fiber HTML templates, PostgreSQL through `pgx`, and filesystem-backed +Fiber v3, Fiber HTML templates, PostgreSQL through `pgx`, and filesystem-backed image uploads. It intentionally uses little browser-side JavaScript. The service supports thread creation, replies, JPEG/PNG attachments, @@ -136,6 +136,11 @@ and prefer table-driven cases when practical. Repository tests must use disposable test data or a dedicated database, never a shared production database. +Routine `go test ./...` must remain DB-free. Fiber handler and middleware tests +must use in-memory apps and temporary directories; PostgreSQL-backed behavior +belongs to the explicit manual smoke pass unless a later design approves an +integration-test harness. + Minimum verification for code changes: 1. Run `go fmt ./...` and review the resulting diff. diff --git a/README.md b/README.md index 6c4c766..4520b4e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ provide; the source remains available for local use and development. ## Stack - Go 1.26 -- Fiber v2 +- Fiber v3 - PostgreSQL 18 for local development - PostgreSQL access through [`pgx`](https://github.com/jackc/pgx) - Plain HTML templates and CSS diff --git a/controllers/captcha_controller.go b/controllers/captcha_controller.go index a2dcac8..5d86919 100644 --- a/controllers/captcha_controller.go +++ b/controllers/captcha_controller.go @@ -5,10 +5,10 @@ import ( "log" "github.com/dchest/captcha" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" ) -func GetCaptcha(c *fiber.Ctx) error { +func GetCaptcha(c fiber.Ctx) error { ID := c.Params("captchaID") var content bytes.Buffer err := captcha.WriteImage(&content, ID, captcha.StdWidth, captcha.StdHeight) @@ -17,6 +17,6 @@ func GetCaptcha(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) } - c.Context().SetContentType("image/png") + c.RequestCtx().SetContentType("image/png") return c.Send(content.Bytes()) } diff --git a/controllers/threads_controller.go b/controllers/threads_controller.go index 8e74b7c..3850b89 100644 --- a/controllers/threads_controller.go +++ b/controllers/threads_controller.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/dchest/captcha" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/jackc/pgx/v5" "micrach/config" @@ -18,8 +18,7 @@ import ( "micrach/utils" ) -func GetThreads(c *fiber.Ctx) error { - ctx := c.UserContext() +func GetThreads(c fiber.Ctx) error { pageString := c.Query("page", "1") page, err := strconv.Atoi(pageString) if err != nil { @@ -32,12 +31,12 @@ func GetThreads(c *fiber.Ctx) error { limit := 10 offset := limit * (page - 1) - threads, err := repositories.Posts.Get(ctx, limit, offset) + threads, err := repositories.Posts.Get(c, limit, offset) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) } - count, err := repositories.Posts.GetThreadsCount(ctx) + count, err := repositories.Posts.GetThreadsCount(c) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) @@ -63,13 +62,12 @@ func GetThreads(c *fiber.Ctx) error { return c.Render("pages/index", htmlData) } -func GetThread(c *fiber.Ctx) error { - ctx := c.UserContext() - threadID, err := c.ParamsInt("threadID") +func GetThread(c fiber.Ctx) error { + threadID, err := strconv.Atoi(c.Params("threadID")) if err != nil { return c.Status(fiber.StatusNotFound).Render("pages/404", nil) } - thread, err := repositories.Posts.GetThreadByPostID(ctx, threadID) + thread, err := repositories.Posts.GetThreadByPostID(c, threadID) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) @@ -91,8 +89,7 @@ func GetThread(c *fiber.Ctx) error { return c.Render("pages/thread", htmlData) } -func CreateThread(c *fiber.Ctx) error { - ctx := c.UserContext() +func CreateThread(c fiber.Ctx) error { form, err := c.MultipartForm() if err != nil { log.Println("error:", err) @@ -123,39 +120,39 @@ func CreateThread(c *fiber.Ctx) error { } } - conn, err := db.Pool.Acquire(ctx) + conn, err := db.Pool.Acquire(c) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) } defer conn.Release() - threadsCount, err := repositories.Posts.GetThreadsCount(ctx) + threadsCount, err := repositories.Posts.GetThreadsCount(c) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) } if threadsCount >= config.App.ThreadsMaxCount { - oldestThreadUpdatedAt, err := repositories.Posts.GetOldestThreadUpdatedAt(ctx) + oldestThreadUpdatedAt, err := repositories.Posts.GetOldestThreadUpdatedAt(c) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) } - err = repositories.Posts.ArchiveThreadsFrom(ctx, oldestThreadUpdatedAt) + err = repositories.Posts.ArchiveThreadsFrom(c, oldestThreadUpdatedAt) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) } } - tx, err := conn.Begin(ctx) + tx, err := conn.Begin(c) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) } defer func() { - rollbackErr := tx.Rollback(ctx) + rollbackErr := tx.Rollback(c) if rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) { log.Println("error:", rollbackErr) } @@ -167,7 +164,7 @@ func CreateThread(c *fiber.Ctx) error { Text: text, IsSage: false, } - threadID, err := repositories.Posts.CreateInTx(ctx, tx, post) + threadID, err := repositories.Posts.CreateInTx(c, tx, post) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) @@ -188,7 +185,7 @@ func CreateThread(c *fiber.Ctx) error { Size: int(fileInRequest.Size), } - fileID, err := repositories.Files.CreateInTx(ctx, tx, file) + fileID, err := repositories.Files.CreateInTx(c, tx, file) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) @@ -219,24 +216,23 @@ func CreateThread(c *fiber.Ctx) error { } } - if err := tx.Commit(ctx); err != nil { + if err := tx.Commit(c); err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) } path := "/" + strconv.Itoa(threadID) - return c.Redirect(path, fiber.StatusFound) + return c.Redirect().Status(fiber.StatusFound).To(path) } // Add new post in thread -func UpdateThread(c *fiber.Ctx) error { - ctx := c.UserContext() - threadID, err := c.ParamsInt("threadID") +func UpdateThread(c fiber.Ctx) error { + threadID, err := strconv.Atoi(c.Params("threadID")) if err != nil { return c.Status(fiber.StatusNotFound).Render("pages/404", nil) } - isArchived, err := repositories.Posts.GetIfThreadIsArchived(ctx, threadID) + isArchived, err := repositories.Posts.GetIfThreadIsArchived(c, threadID) if isArchived { errorHtmlData := repositories.BadRequestHtmlData{ Message: repositories.ThreadIsArchivedErrorMessage, @@ -284,7 +280,7 @@ func UpdateThread(c *fiber.Ctx) error { } isSage := isSageString == "on" - conn, err := db.Pool.Acquire(ctx) + conn, err := db.Pool.Acquire(c) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) @@ -292,14 +288,14 @@ func UpdateThread(c *fiber.Ctx) error { } defer conn.Release() - tx, err := conn.Begin(ctx) + tx, err := conn.Begin(c) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) } defer func() { - rollbackErr := tx.Rollback(ctx) + rollbackErr := tx.Rollback(c) if rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) { log.Println("error:", rollbackErr) } @@ -312,14 +308,14 @@ func UpdateThread(c *fiber.Ctx) error { Text: text, IsSage: isSage, } - postID, err := repositories.Posts.CreateInTx(ctx, tx, post) + postID, err := repositories.Posts.CreateInTx(c, tx, post) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) } - postsCountInThread, err := repositories.Posts.GetThreadPostsCount(ctx, threadID) + postsCountInThread, err := repositories.Posts.GetThreadPostsCount(c, threadID) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) @@ -328,7 +324,7 @@ func UpdateThread(c *fiber.Ctx) error { isBumpLimit := postsCountInThread >= config.App.ThreadBumpLimit isThreadBumped := !isBumpLimit && !isSage && !post.IsParent if isThreadBumped { - err = repositories.Posts.BumpThreadInTx(ctx, tx, threadID) + err = repositories.Posts.BumpThreadInTx(c, tx, threadID) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) @@ -345,7 +341,7 @@ func UpdateThread(c *fiber.Ctx) error { Size: int(fileInRequest.Size), } - fileID, err := repositories.Files.CreateInTx(ctx, tx, file) + fileID, err := repositories.Files.CreateInTx(c, tx, file) if err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) @@ -380,11 +376,11 @@ func UpdateThread(c *fiber.Ctx) error { } } - if err := tx.Commit(ctx); err != nil { + if err := tx.Commit(c); err != nil { log.Println("error:", err) return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) } path := "/" + strconv.Itoa(threadID) + "#" + strconv.Itoa(postID) - return c.Redirect(path) + return c.Redirect().Status(fiber.StatusFound).To(path) } diff --git a/controllers/threads_controller_test.go b/controllers/threads_controller_test.go index 2e6cff3..450b476 100644 --- a/controllers/threads_controller_test.go +++ b/controllers/threads_controller_test.go @@ -1,18 +1,23 @@ package controllers import ( + "bytes" "io" + "mime/multipart" + "net/http" "net/http/httptest" "testing" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" + + "micrach/config" ) type testViews struct{} func (testViews) Load() error { return nil } -func (testViews) Render(out io.Writer, name string, _ interface{}, _ ...string) error { +func (testViews) Render(out io.Writer, name string, _ any, _ ...string) error { _, err := io.WriteString(out, name) return err } @@ -44,3 +49,37 @@ func TestGetThreadRejectsInvalidID(t *testing.T) { t.Fatalf("status = %d, want %d", resp.StatusCode, fiber.StatusNotFound) } } + +func TestCreateThreadRejectsInvalidMultipartBeforeDatabase(t *testing.T) { + previousConfig := config.App + t.Cleanup(func() { + config.App = previousConfig + }) + config.App.IsCaptchaActive = false + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", ""); err != nil { + t.Fatal(err) + } + if err := writer.WriteField("text", ""); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + app := fiber.New(fiber.Config{Views: testViews{}}) + app.Post("/", CreateThread) + + req := httptest.NewRequest(http.MethodPost, "/", &body) + req.Header.Set(fiber.HeaderContentType, writer.FormDataContentType()) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, fiber.StatusBadRequest) + } +} diff --git a/docs/superpowers/modernization-followups.md b/docs/superpowers/modernization-followups.md new file mode 100644 index 0000000..2372205 --- /dev/null +++ b/docs/superpowers/modernization-followups.md @@ -0,0 +1,11 @@ +# Modernization Follow-ups + +Updated: 2026-07-16 + +Wave 2 completed the confirmed Fiber v3 and template-engine major upgrades. +The final direct-module, deprecated-API, `go vet`, and `govulncheck` audits found +no remaining major-version or deprecated-API candidate requiring a scheduled +follow-up. + +Recheck this file during the next deliberate dependency refresh. Do not add +speculative architecture cleanup or ordinary patch/minor updates here. diff --git a/gateway/controllers.go b/gateway/controllers.go index bf1ed9a..aa54e30 100644 --- a/gateway/controllers.go +++ b/gateway/controllers.go @@ -3,10 +3,10 @@ package gateway import ( "micrach/config" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" ) -func Ping(c *fiber.Ctx) error { +func Ping(c fiber.Ctx) error { headerValues := c.GetReqHeaders()["Authorization"] headerKey := "" if len(headerValues) > 0 { diff --git a/gateway/controllers_test.go b/gateway/controllers_test.go index 3cf82d5..5ae18f1 100644 --- a/gateway/controllers_test.go +++ b/gateway/controllers_test.go @@ -4,7 +4,7 @@ import ( "net/http/httptest" "testing" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "micrach/config" ) diff --git a/go.mod b/go.mod index 55350f5..c66ce80 100644 --- a/go.mod +++ b/go.mod @@ -7,32 +7,32 @@ toolchain go1.26.5 require ( github.com/dchest/captcha v1.1.0 github.com/disintegration/imaging v1.6.2 - github.com/gofiber/fiber/v2 v2.52.14 - github.com/gofiber/template/html/v2 v2.1.3 + github.com/gofiber/fiber/v3 v3.4.0 + github.com/gofiber/template/html/v3 v3.0.6 github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 ) require ( - github.com/andybalholm/brotli v1.1.0 // indirect - github.com/gofiber/template v1.8.3 // indirect - github.com/gofiber/utils v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/gofiber/schema v1.8.0 // indirect + github.com/gofiber/template/v2 v2.1.0 // indirect + github.com/gofiber/utils/v2 v2.1.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.17.9 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect - github.com/rivo/uniseg v0.2.0 // indirect - github.com/tinylib/msgp v1.2.5 // indirect + github.com/klauspost/compress v1.19.0 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect + github.com/valyala/fasthttp v1.72.0 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/image v0.44.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index d46057c..c0b7127 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -7,14 +7,18 @@ github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ= github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= -github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE= -github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= -github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= -github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o= -github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE= -github.com/gofiber/utils v1.2.0 h1:NCaqd+Efg3khhN++eeUUTyBz+byIxAsmIjpl8kKOMIc= -github.com/gofiber/utils v1.2.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gofiber/fiber/v3 v3.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw= +github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk= +github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis= +github.com/gofiber/schema v1.8.0/go.mod h1:lmbXPQ8hvzXSLkdS2DS7pb4kpunC2Roh7Sj3HMjGfzA= +github.com/gofiber/template/html/v3 v3.0.6 h1:Vl/rOaz4MddT2yn2x60nvAjCVL1D59Av7CYxxd5NuHg= +github.com/gofiber/template/html/v3 v3.0.6/go.mod h1:5WB97oy0WhJ8OpGfIu+kbPOavWFrOrSJcpAx6rxgJlk= +github.com/gofiber/template/v2 v2.1.0 h1:vrLY6uEW2HdioJm6J5FGUpYZuapVQhHciNz21XQjR/4= +github.com/gofiber/template/v2 v2.1.0/go.mod h1:ohgpR/Ng90nJbK+IyNzrgR/XpnBNt862/oTF5G7SAmE= +github.com/gofiber/utils/v2 v2.1.1 h1:kGnoGjwEnFW6w0x45W+kLlmMJvqBGkuUA4oMWKn/T/I= +github.com/gofiber/utils/v2 v2.1.1/go.mod h1:DdOgEVwQTi8cou/AKWPqhXOR4fHGRVhA/rEWL3IXG7Q= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -27,43 +31,44 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= -github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw= +github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= -github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M= +github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= diff --git a/main.go b/main.go index 746f6d9..633bf60 100644 --- a/main.go +++ b/main.go @@ -8,11 +8,12 @@ import ( _ "github.com/joho/godotenv/autoload" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/compress" - "github.com/gofiber/fiber/v2/middleware/limiter" - "github.com/gofiber/fiber/v2/middleware/recover" - "github.com/gofiber/template/html/v2" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/compress" + "github.com/gofiber/fiber/v3/middleware/limiter" + "github.com/gofiber/fiber/v3/middleware/recover" + "github.com/gofiber/fiber/v3/middleware/static" + "github.com/gofiber/template/html/v3" "micrach/build" "micrach/config" @@ -24,6 +25,21 @@ import ( "micrach/utils" ) +// Reserve 1 MiB for multipart boundaries, filenames, title, and text. +const multipartBodyLimit = 4*utils.FILE_SIZE_IN_BYTES + (1 << 20) + +func skipRateLimit(isLocal bool, path string) bool { + isRequestForStatic := strings.Contains(path, "/static") || + strings.Contains(path, "/uploads") || + strings.Contains(path, "/captcha") + return isRequestForStatic || isLocal +} + +func registerStaticRoutes(app *fiber.App) { + app.Get("/uploads/*", static.New("./uploads")) + app.Get("/static/*", static.New("./static")) +} + func main() { ctx := context.Background() config.Init() @@ -48,24 +64,23 @@ func main() { engine.AddFunc("Iterate", templates.Iterate) engine.AddFunc("NotNil", templates.NotNil) - app := fiber.New(fiber.Config{Views: engine}) + app := fiber.New(fiber.Config{ + Views: engine, + BodyLimit: multipartBodyLimit, + }) app.Use(recover.New()) if config.App.IsRateLimiterEnabled { app.Use(limiter.New(limiter.Config{ - Next: func(c *fiber.Ctx) bool { - isDev := c.IsFromLocal() - path := c.Path() - isRequestForStatic := strings.Contains(path, "/static") || strings.Contains(path, "/uploads") || strings.Contains(path, "/captcha") - return isRequestForStatic || isDev + Next: func(c fiber.Ctx) bool { + return skipRateLimit(c.IsFromLocal(), c.Path()) }, Max: 50, })) } app.Use(compress.New()) - app.Static("/uploads", "./uploads") - app.Static("/static", "./static") + registerStaticRoutes(app) app.Get("/", controllers.GetThreads) app.Post("/", controllers.CreateThread) diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..dc49fe6 --- /dev/null +++ b/main_test.go @@ -0,0 +1,223 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/limiter" + + "micrach/config" + "micrach/controllers" + "micrach/utils" +) + +type mainTestViews struct{} + +func (mainTestViews) Load() error { return nil } + +func (mainTestViews) Render(out io.Writer, name string, _ any, _ ...string) error { + _, err := io.WriteString(out, name) + return err +} + +func TestRegisterStaticRoutes(t *testing.T) { + t.Chdir(t.TempDir()) + + files := map[string]string{ + "static/styles/app.css": "body { color: black; }", + "uploads/42/o/7.png": "png-body", + } + for name, content := range files { + if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil { + t.Fatalf("create fixture directory: %v", err) + } + if err := os.WriteFile(name, []byte(content), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + } + + app := fiber.New() + registerStaticRoutes(app) + app.Get("/static/fallback", func(c fiber.Ctx) error { + return c.SendString("fallback") + }) + + tests := []struct { + name string + path string + wantCode int + wantBody string + }{ + {name: "checked-in static file", path: "/static/styles/app.css", wantCode: fiber.StatusOK, wantBody: files["static/styles/app.css"]}, + {name: "generated upload", path: "/uploads/42/o/7.png", wantCode: fiber.StatusOK, wantBody: files["uploads/42/o/7.png"]}, + {name: "missing file continues routing", path: "/static/fallback", wantCode: fiber.StatusOK, wantBody: "fallback"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := app.Test(httptest.NewRequest(http.MethodGet, tt.path, nil)) + if err != nil { + t.Fatalf("request %s: %v", tt.path, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response: %v", err) + } + if resp.StatusCode != tt.wantCode || string(body) != tt.wantBody { + t.Fatalf("response = (%d, %q), want (%d, %q)", resp.StatusCode, body, tt.wantCode, tt.wantBody) + } + }) + } +} + +func TestSkipRateLimit(t *testing.T) { + tests := []struct { + name string + isLocal bool + path string + want bool + }{ + {name: "ordinary remote request", path: "/", want: false}, + {name: "local request", isLocal: true, path: "/", want: true}, + {name: "static", path: "/static/styles/index.css", want: true}, + {name: "upload", path: "/uploads/1/o/1.png", want: true}, + {name: "captcha", path: "/captcha/id", want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := skipRateLimit(tt.isLocal, tt.path); got != tt.want { + t.Fatalf("skipRateLimit(%v, %q) = %v, want %v", tt.isLocal, tt.path, got, tt.want) + } + }) + } +} + +func TestLimiterRejectsOrdinaryRemoteRequests(t *testing.T) { + app := fiber.New() + app.Use(limiter.New(limiter.Config{ + Next: func(c fiber.Ctx) bool { + return skipRateLimit(c.IsFromLocal(), c.Path()) + }, + Max: 2, + })) + app.Get("/", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + for requestNumber, want := range []int{fiber.StatusOK, fiber.StatusOK, fiber.StatusTooManyRequests} { + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/", nil)) + if err != nil { + t.Fatalf("request %d: %v", requestNumber+1, err) + } + resp.Body.Close() + if resp.StatusCode != want { + t.Fatalf("request %d status = %d, want %d", requestNumber+1, resp.StatusCode, want) + } + } +} + +func TestMultipartBodyLimitAllowsApplicationFileSizeValidation(t *testing.T) { + previousConfig := config.App + t.Cleanup(func() { + config.App = previousConfig + }) + config.App.IsCaptchaActive = false + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", "oversize"); err != nil { + t.Fatal(err) + } + if err := writer.WriteField("text", "oversize upload"); err != nil { + t.Fatal(err) + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", `form-data; name="files"; filename="oversize.png"`) + header.Set("Content-Type", "image/png") + part, err := writer.CreatePart(header) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(bytes.Repeat([]byte("x"), utils.FILE_SIZE_IN_BYTES+(1<<20))); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + app := fiber.New(fiber.Config{Views: mainTestViews{}, BodyLimit: multipartBodyLimit}) + app.Post("/", controllers.CreateThread) + req := httptest.NewRequest(http.MethodPost, "/", &body) + req.Header.Set(fiber.HeaderContentType, writer.FormDataContentType()) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, fiber.StatusBadRequest) + } +} + +func TestMultipartBodyLimitAllowsMaximumUploadEnvelope(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", strings.Repeat("t", 100)); err != nil { + t.Fatal(err) + } + if err := writer.WriteField("text", strings.Repeat("t", 1000)); err != nil { + t.Fatal(err) + } + for fileNumber := 1; fileNumber <= 4; fileNumber++ { + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="files"; filename="maximum-%d.png"`, fileNumber)) + header.Set("Content-Type", "image/png") + part, err := writer.CreatePart(header) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(bytes.Repeat([]byte("x"), utils.FILE_SIZE_IN_BYTES)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if body.Len() >= multipartBodyLimit { + t.Fatalf("maximum valid multipart body = %d bytes, limit = %d", body.Len(), multipartBodyLimit) + } + + app := fiber.New(fiber.Config{BodyLimit: multipartBodyLimit}) + app.Post("/", func(c fiber.Ctx) error { + form, err := c.MultipartForm() + if err != nil { + return err + } + if got := len(form.File["files"]); got != 4 { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("files = %d, want 4", got)) + } + return c.SendStatus(fiber.StatusNoContent) + }) + req := httptest.NewRequest(http.MethodPost, "/", &body) + req.Header.Set(fiber.HeaderContentType, writer.FormDataContentType()) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != fiber.StatusNoContent { + t.Fatalf("status = %d, want %d", resp.StatusCode, fiber.StatusNoContent) + } +} diff --git a/templates/templates_test.go b/templates/templates_test.go new file mode 100644 index 0000000..71a1f08 --- /dev/null +++ b/templates/templates_test.go @@ -0,0 +1,30 @@ +package templates + +import ( + "bytes" + "strings" + "testing" + + "github.com/gofiber/template/html/v3" + + "micrach/repositories" +) + +func TestHTMLTemplatesLoadAndRender(t *testing.T) { + engine := html.New("../templates", ".html") + engine.AddFunc("Iterate", Iterate) + engine.AddFunc("NotNil", NotNil) + + if err := engine.Load(); err != nil { + t.Fatalf("load templates: %v", err) + } + + const message = "template compatibility" + var rendered bytes.Buffer + if err := engine.Render(&rendered, "pages/400", repositories.BadRequestHtmlData{Message: message}); err != nil { + t.Fatalf("render template: %v", err) + } + if !strings.Contains(rendered.String(), message) { + t.Fatalf("rendered template does not contain %q", message) + } +}