From 6b697e008fe3088804c9511db5c957422efbebb2 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 2 Mar 2026 20:48:41 +1100 Subject: [PATCH 1/6] Have got uploads working --- assets/js/controllers/upload.js | 97 ++++++++++++++ assets/js/main.js | 4 +- cmds/server.go | 6 + config/config.go | 1 + handlers/posts.go | 5 - handlers/uploads.go | 62 +++++++++ models/ctx.go | 2 +- models/uploads.go | 23 ++++ providers/db/gen/sqlgen/models.go | 21 +++ .../db/gen/sqlgen/pending_uploads.sql.go | 68 ++++++++++ providers/db/gen/sqlgen/uploads.sql.go | 120 +++++++++++++++++ providers/db/uploads.go | 107 +++++++++++++++ services/posts/delete.go | 7 + services/services.go | 4 + services/uploads/pending.go | 124 ++++++++++++++++++ services/uploads/services.go | 38 ++++++ sql/queries/pending_uploads.sql | 14 ++ sql/queries/uploads.sql | 22 ++++ sql/schema/02_upload.up.sql | 27 ++++ views/uploads/index.html | 6 + 20 files changed, 751 insertions(+), 7 deletions(-) create mode 100644 assets/js/controllers/upload.js create mode 100644 handlers/uploads.go create mode 100644 models/uploads.go create mode 100644 providers/db/gen/sqlgen/pending_uploads.sql.go create mode 100644 providers/db/gen/sqlgen/uploads.sql.go create mode 100644 providers/db/uploads.go create mode 100644 services/uploads/pending.go create mode 100644 services/uploads/services.go create mode 100644 sql/queries/pending_uploads.sql create mode 100644 sql/queries/uploads.sql create mode 100644 sql/schema/02_upload.up.sql create mode 100644 views/uploads/index.html diff --git a/assets/js/controllers/upload.js b/assets/js/controllers/upload.js new file mode 100644 index 0000000..a810d41 --- /dev/null +++ b/assets/js/controllers/upload.js @@ -0,0 +1,97 @@ +import { Controller } from "@hotwired/stimulus" + +export default class UploadController extends Controller { + static values = { + siteId: Number, + }; + + upload(ev) { + ev.preventDefault(); + + this._promptForUpload((files) => { + this._doUploads(files); + }) + } + + _promptForUpload(onAccept) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.multiple = true; + + input.onchange = (e) => { + const files = Array.from(e.target.files); + if (files.length > 0) { + onAccept(files); + } + }; + + input.click(); + } + + async _doUploads(files) { + for (let file of files) { + await this._doUpload(file); + } + } + + async _doUpload(file) { + console.log(`Uploading ${file.name}: new pending`); + + // Prepare upload of file supplying size and mime-type + let newPending = await (await fetch(`/sites/${this.siteIdValue}/uploads/pending`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + size: file.size, + mime: file.type, + name: file.name, + }) + })).json(); + + // Upload file in 2 MB blocks + let offset = 0; + let chunkSize = 2 * 1024 * 1024; + while (offset < file.size) { + let chunk = file.slice(offset, offset + chunkSize); + + console.log(`Uploading ${file.name}: uploading part`); + await fetch(`/sites/${this.siteIdValue}/uploads/pending/${newPending.guid}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: chunk + }); + + offset += chunkSize; + } + + // Calculate SHA256 hash + const hash = await this._calculateSHA256(file); + + // Finalise upload + console.log(`Uploading ${file.name}: finalise`); + await fetch(`/sites/${this.siteIdValue}/uploads/pending/${newPending.guid}/finalize`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + hash: hash + }) + }); + } + + async _calculateSHA256(file) { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashHex; + } +} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index 6bca555..3e8c345 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -5,10 +5,12 @@ import PostlistController from "./controllers/postlist"; import PosteditController from "./controllers/postedit"; import LogoutController from "./controllers/logout"; import FirstRunController from "./controllers/firstrun"; +import UploadController from "./controllers/upload"; window.Stimulus = Application.start() Stimulus.register("toast", ToastController); Stimulus.register("postlist", PostlistController); Stimulus.register("postedit", PosteditController); Stimulus.register("logout", LogoutController); -Stimulus.register("first-run", FirstRunController); \ No newline at end of file +Stimulus.register("first-run", FirstRunController); +Stimulus.register("upload", UploadController); \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index ccf11f7..e12d265 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -108,6 +108,7 @@ Starting weiro without any arguments will start the server. ih := handlers.IndexHandler{SiteService: svcs.Sites} lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth} ph := handlers.PostsHandler{PostService: svcs.Posts} + uh := handlers.UploadsHandler{UploadsService: svcs.Uploads} app.Get("/login", lh.Login) app.Post("/login", lh.DoLogin) @@ -122,6 +123,11 @@ Starting weiro without any arguments will start the server. siteGroup.Patch("/posts/:postID", ph.Patch) siteGroup.Delete("/posts/:postID", ph.Delete) + siteGroup.Get("/uploads", uh.Index) + siteGroup.Post("/uploads/pending", uh.New) + siteGroup.Post("/uploads/pending/:guid", uh.UploadPart) + siteGroup.Post("/uploads/pending/:guid/finalize", uh.UploadComplete) + app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index) app.Get("/first-run", ih.FirstRun) app.Post("/first-run", ih.FirstRunSubmit) diff --git a/config/config.go b/config/config.go index 38cde39..56585a7 100644 --- a/config/config.go +++ b/config/config.go @@ -9,6 +9,7 @@ import ( type Config struct { DataDir string `env:"DATA_DIR"` + ScratchDir string `env:"SCRATCH_DIR"` SiteDomain string `env:"SITE_DOMAIN"` LoginLocked bool `env:"LOGIN_LOCKED,default=false"` Env string `env:"ENV,default=prod"` diff --git a/handlers/posts.go b/handlers/posts.go index 041ec9d..1bab15d 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -2,7 +2,6 @@ package handlers import ( "fmt" - "log" "strconv" "github.com/gofiber/fiber/v3" @@ -92,8 +91,6 @@ func (ph PostsHandler) Update(c fiber.Ctx) error { } func (ph PostsHandler) Patch(c fiber.Ctx) error { - log.Println("PATCH") - postIDStr := c.Params("postID") if postIDStr == "" { return fiber.ErrBadRequest @@ -110,8 +107,6 @@ func (ph PostsHandler) Patch(c fiber.Ctx) error { return err } - log.Println("Request") - switch req.Action { case "restore": if err := ph.PostService.RestorePost(c.Context(), postID); err != nil { diff --git a/handlers/uploads.go b/handlers/uploads.go new file mode 100644 index 0000000..3d6e891 --- /dev/null +++ b/handlers/uploads.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/services/uploads" +) + +type UploadsHandler struct { + UploadsService *uploads.Service +} + +func (uh UploadsHandler) Index(c fiber.Ctx) error { + return c.Render("uploads/index", nil) +} + +func (uh UploadsHandler) New(c fiber.Ctx) error { + var req uploads.NewPendingRequest + + if err := c.Bind().Body(&req); err != nil { + return err + } + + res, err := uh.UploadsService.NewPending(c.Context(), req) + if err != nil { + return err + } + + return c.JSON(res) +} + +func (uh UploadsHandler) UploadPart(c fiber.Ctx) error { + guid := c.Params("guid") + if guid == "" { + return fiber.ErrBadRequest + } + + if err := uh.UploadsService.WriteToPending(c.Context(), guid, c.Body()); err != nil { + return err + } + + return c.Status(fiber.StatusAccepted).JSON(fiber.Map{}) +} + +func (uh UploadsHandler) UploadComplete(c fiber.Ctx) error { + guid := c.Params("guid") + if guid == "" { + return fiber.ErrBadRequest + } + + var res struct { + Hash string `json:"hash"` + } + if err := c.Bind().Body(&res); err != nil { + return err + } + + if err := uh.UploadsService.FinalizePending(c.Context(), guid, res.Hash); err != nil { + return err + } + + return c.Status(fiber.StatusAccepted).JSON(fiber.Map{}) +} diff --git a/models/ctx.go b/models/ctx.go index 8eca001..60f93fe 100644 --- a/models/ctx.go +++ b/models/ctx.go @@ -6,7 +6,7 @@ type userKeyType struct{} type siteKeyType struct{} var userKey = userKeyType{} -var siteKey = userKeyType{} +var siteKey = siteKeyType{} func WithUser(ctx context.Context, user User) context.Context { return context.WithValue(ctx, userKey, user) diff --git a/models/uploads.go b/models/uploads.go new file mode 100644 index 0000000..0e818c5 --- /dev/null +++ b/models/uploads.go @@ -0,0 +1,23 @@ +package models + +import "time" + +type Upload struct { + ID int64 `json:"id"` + SiteID int64 `json:"site_id"` + GUID string `json:"guid"` + MIMEType string `json:"mime_type"` + Filename string `json:"filename"` + CreatedAt int64 `json:"created_at"` + Alt string `json:"alt"` +} + +type PendingUpload struct { + GUID string `json:"guid"` + SiteID int64 `json:"site_id"` + UserID int64 `json:"user_id"` + FileSize int64 `json:"file_size"` + Filename string `json:"filename"` + MIMEType string `json:"mime_type"` + UploadStarted time.Time `json:"upload_started"` +} diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 5317ef2..6aed257 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -4,6 +4,17 @@ package sqlgen +type PendingUpload struct { + ID interface{} + SiteID int64 + Guid string + UserID int64 + Filename string + FileSize int64 + MimeType string + UploadStartedAt int64 +} + type Post struct { ID int64 SiteID int64 @@ -38,6 +49,16 @@ type Site struct { CreatedAt int64 } +type Upload struct { + ID interface{} + SiteID int64 + Guid string + MimeType string + Filename string + Alt string + CreatedAt int64 +} + type User struct { ID int64 Username string diff --git a/providers/db/gen/sqlgen/pending_uploads.sql.go b/providers/db/gen/sqlgen/pending_uploads.sql.go new file mode 100644 index 0000000..c2ca66b --- /dev/null +++ b/providers/db/gen/sqlgen/pending_uploads.sql.go @@ -0,0 +1,68 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: pending_uploads.sql + +package sqlgen + +import ( + "context" +) + +const insertPendingUpload = `-- name: InsertPendingUpload :one +INSERT INTO pending_uploads ( + site_id, + guid, + user_id, + filename, + file_size, + mime_type, + upload_started_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id +` + +type InsertPendingUploadParams struct { + SiteID int64 + Guid string + UserID int64 + Filename string + FileSize int64 + MimeType string + UploadStartedAt int64 +} + +func (q *Queries) InsertPendingUpload(ctx context.Context, arg InsertPendingUploadParams) (interface{}, error) { + row := q.db.QueryRowContext(ctx, insertPendingUpload, + arg.SiteID, + arg.Guid, + arg.UserID, + arg.Filename, + arg.FileSize, + arg.MimeType, + arg.UploadStartedAt, + ) + var id interface{} + err := row.Scan(&id) + return id, err +} + +const selectPendingUploadByGUID = `-- name: SelectPendingUploadByGUID :one +SELECT id, site_id, guid, user_id, filename, file_size, mime_type, upload_started_at FROM pending_uploads WHERE guid = ? LIMIT 1 +` + +func (q *Queries) SelectPendingUploadByGUID(ctx context.Context, guid string) (PendingUpload, error) { + row := q.db.QueryRowContext(ctx, selectPendingUploadByGUID, guid) + var i PendingUpload + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.UserID, + &i.Filename, + &i.FileSize, + &i.MimeType, + &i.UploadStartedAt, + ) + return i, err +} diff --git a/providers/db/gen/sqlgen/uploads.sql.go b/providers/db/gen/sqlgen/uploads.sql.go new file mode 100644 index 0000000..6891422 --- /dev/null +++ b/providers/db/gen/sqlgen/uploads.sql.go @@ -0,0 +1,120 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: uploads.sql + +package sqlgen + +import ( + "context" +) + +const deleteUpload = `-- name: DeleteUpload :exec +DELETE FROM uploads WHERE id = ? +` + +func (q *Queries) DeleteUpload(ctx context.Context, id interface{}) error { + _, err := q.db.ExecContext(ctx, deleteUpload, id) + return err +} + +const insertUpload = `-- name: InsertUpload :exec +INSERT INTO uploads ( + site_id, + guid, + mime_type, + filename, + created_at, + alt +) VALUES (?, ?, ?, ?, ?, ?) +RETURNING id +` + +type InsertUploadParams struct { + SiteID int64 + Guid string + MimeType string + Filename string + CreatedAt int64 + Alt string +} + +func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) error { + _, err := q.db.ExecContext(ctx, insertUpload, + arg.SiteID, + arg.Guid, + arg.MimeType, + arg.Filename, + arg.CreatedAt, + arg.Alt, + ) + return err +} + +const selectUploadByID = `-- name: SelectUploadByID :one +SELECT id, site_id, guid, mime_type, filename, alt, created_at FROM uploads WHERE id = ? +` + +func (q *Queries) SelectUploadByID(ctx context.Context, id interface{}) (Upload, error) { + row := q.db.QueryRowContext(ctx, selectUploadByID, id) + var i Upload + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.MimeType, + &i.Filename, + &i.Alt, + &i.CreatedAt, + ) + return i, err +} + +const selectUploadsOfSite = `-- name: SelectUploadsOfSite :many +SELECT id, site_id, guid, mime_type, filename, alt, created_at FROM uploads WHERE site_id = ? ORDER BY created_at DESC +` + +func (q *Queries) SelectUploadsOfSite(ctx context.Context, siteID int64) ([]Upload, error) { + rows, err := q.db.QueryContext(ctx, selectUploadsOfSite, siteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Upload + for rows.Next() { + var i Upload + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.MimeType, + &i.Filename, + &i.Alt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUpload = `-- name: UpdateUpload :exec +UPDATE uploads SET alt = ? WHERE id = ? +` + +type UpdateUploadParams struct { + Alt string + ID interface{} +} + +func (q *Queries) UpdateUpload(ctx context.Context, arg UpdateUploadParams) error { + _, err := q.db.ExecContext(ctx, updateUpload, arg.Alt, arg.ID) + return err +} diff --git a/providers/db/uploads.go b/providers/db/uploads.go new file mode 100644 index 0000000..f65b794 --- /dev/null +++ b/providers/db/uploads.go @@ -0,0 +1,107 @@ +package db + +import ( + "context" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db/gen/sqlgen" +) + +func (db *Provider) SelectUploadByID(ctx context.Context, id int64) (models.Upload, error) { + row, err := db.queries.SelectUploadByID(ctx, id) + if err != nil { + return models.Upload{}, err + } + + return dbUploadToUpload(row), nil +} + +func (db *Provider) SelectUploadsOfSite(ctx context.Context, siteID int64) ([]models.Upload, error) { + rows, err := db.queries.SelectUploadsOfSite(ctx, siteID) + if err != nil { + return nil, err + } + + uploads := make([]models.Upload, len(rows)) + for i, row := range rows { + uploads[i] = dbUploadToUpload(row) + } + return uploads, nil +} + +func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error { + if upload.ID == 0 { + if err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{ + SiteID: upload.SiteID, + Guid: upload.GUID, + MimeType: upload.MIMEType, + Filename: upload.Filename, + CreatedAt: upload.CreatedAt, + Alt: upload.Alt, + }); err != nil { + return err + } + return nil + } + + return db.queries.UpdateUpload(ctx, sqlgen.UpdateUploadParams{ + Alt: upload.Alt, + ID: upload.ID, + }) +} + +func (db *Provider) DeleteUpload(ctx context.Context, id int64) error { + return db.queries.DeleteUpload(ctx, id) +} + +func (db *Provider) SelectPendingUploadByGUID(ctx context.Context, guid string) (models.PendingUpload, error) { + row, err := db.queries.SelectPendingUploadByGUID(ctx, guid) + if err != nil { + return models.PendingUpload{}, err + } + + return dbPendingUploadToPendingUpload(row), nil +} + +func (db *Provider) SavePendingUpload(ctx context.Context, pending *models.PendingUpload) error { + _, err := db.queries.InsertPendingUpload(ctx, sqlgen.InsertPendingUploadParams{ + SiteID: pending.SiteID, + Guid: pending.GUID, + UserID: pending.UserID, + Filename: pending.Filename, + FileSize: pending.FileSize, + MimeType: pending.MIMEType, + UploadStartedAt: pending.UploadStarted.Unix(), + }) + return err +} + +func dbUploadToUpload(row sqlgen.Upload) models.Upload { + var id int64 + if idVal, ok := row.ID.(int64); ok { + id = idVal + } + + return models.Upload{ + ID: id, + SiteID: row.SiteID, + GUID: row.Guid, + MIMEType: row.MimeType, + Filename: row.Filename, + Alt: row.Alt, + CreatedAt: row.CreatedAt, + } +} + +func dbPendingUploadToPendingUpload(row sqlgen.PendingUpload) models.PendingUpload { + return models.PendingUpload{ + GUID: row.Guid, + SiteID: row.SiteID, + UserID: row.UserID, + FileSize: row.FileSize, + Filename: row.Filename, + MIMEType: row.MimeType, + UploadStarted: time.Unix(row.UploadStartedAt, 0), + } +} diff --git a/services/posts/delete.go b/services/posts/delete.go index df220ef..c7c1b17 100644 --- a/services/posts/delete.go +++ b/services/posts/delete.go @@ -57,6 +57,13 @@ func (s *Service) fetchPostAndSite(ctx context.Context, pid int64) (*models.Post return nil, models.Site{}, models.SiteRequiredError } + user, ok := models.GetUser(ctx) + if !ok { + return nil, models.Site{}, models.UserRequiredError + } else if site.OwnerID != user.ID { + return nil, models.Site{}, models.PermissionError + } + post, err := s.db.SelectPost(ctx, pid) if err != nil { return nil, models.Site{}, err diff --git a/services/services.go b/services/services.go index edf52cd..7bfd120 100644 --- a/services/services.go +++ b/services/services.go @@ -9,6 +9,7 @@ import ( "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" "lmika.dev/lmika/weiro/services/sites" + "lmika.dev/lmika/weiro/services/uploads" ) type Services struct { @@ -18,6 +19,7 @@ type Services struct { PublisherQueue *publisher.Queue Posts *posts.Service Sites *sites.Service + Uploads *uploads.Service } func New(cfg config.Config) (*Services, error) { @@ -31,6 +33,7 @@ func New(cfg config.Config) (*Services, error) { publisherQueue := publisher.NewQueue(publisherSvc) postService := posts.New(dbp, publisherQueue) siteService := sites.New(dbp) + uploadService := uploads.New(dbp, filepath.Join(cfg.ScratchDir, "uploads", "pending")) return &Services{ DB: dbp, @@ -39,6 +42,7 @@ func New(cfg config.Config) (*Services, error) { PublisherQueue: publisherQueue, Posts: postService, Sites: siteService, + Uploads: uploadService, }, nil } diff --git a/services/uploads/pending.go b/services/uploads/pending.go new file mode 100644 index 0000000..a6dd5d3 --- /dev/null +++ b/services/uploads/pending.go @@ -0,0 +1,124 @@ +package uploads + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "time" + + "emperror.dev/errors" + "lmika.dev/lmika/weiro/models" +) + +type NewPendingRequest struct { + FileSize int64 `json:"size"` + Filename string `json:"name"` + MIMEType string `json:"type"` +} + +func (s *Service) NewPending(ctx context.Context, req NewPendingRequest) (models.PendingUpload, error) { + site, user, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.PendingUpload{}, err + } + + pending := models.PendingUpload{ + GUID: models.NewNanoID(), + SiteID: site.ID, + UserID: user.ID, + FileSize: req.FileSize, + Filename: req.Filename, + MIMEType: req.MIMEType, + UploadStarted: time.Now(), + } + if err := s.db.SavePendingUpload(ctx, &pending); err != nil { + return models.PendingUpload{}, err + } + + if err := os.MkdirAll(s.pendingDir, 0755); err != nil { + return models.PendingUpload{}, err + } + + pendingDataFile, err := os.Create(filepath.Join(s.pendingDir, pending.GUID+".upload")) + if err != nil { + return models.PendingUpload{}, err + } + return pending, pendingDataFile.Close() +} + +func (s *Service) WriteToPending(ctx context.Context, pendingGUID string, data []byte) error { + site, user, err := s.fetchSiteAndUser(ctx) + if err != nil { + return err + } + + pu, err := s.db.SelectPendingUploadByGUID(ctx, pendingGUID) + if err != nil { + return err + } else if pu.SiteID != site.ID || pu.UserID != user.ID { + return errors.New("invalid pending upload") + } + + pendingDataFilename := filepath.Join(s.pendingDir, pu.GUID+".upload") + if _, err := os.Stat(pendingDataFilename); err != nil { + return err + } + + pendingDataFile, err := os.OpenFile(pendingDataFilename, os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return err + } + defer pendingDataFile.Close() + + pendingDataFile.Seek(0, io.SeekEnd) + if _, err := pendingDataFile.Write(data); err != nil { + return err + } + + return nil +} + +func (s *Service) FinalizePending(ctx context.Context, pendingGUID string, expectedHash string) error { + site, user, err := s.fetchSiteAndUser(ctx) + if err != nil { + return err + } + + pu, err := s.db.SelectPendingUploadByGUID(ctx, pendingGUID) + if err != nil { + return err + } else if pu.SiteID != site.ID || pu.UserID != user.ID { + return errors.New("invalid pending upload") + } + + expectedHashBytes, err := hex.DecodeString(expectedHash) + if err != nil { + return err + } + + pendingDataFilename := filepath.Join(s.pendingDir, pu.GUID+".upload") + if _, err := os.Stat(pendingDataFilename); err != nil { + return err + } + + pendingDataFile, err := os.Open(pendingDataFilename) + if err != nil { + return err + } + defer pendingDataFile.Close() + + shaSum := sha256.New() + if _, err := io.Copy(shaSum, pendingDataFile); err != nil { + return err + } + + if !bytes.Equal(shaSum.Sum(nil), expectedHashBytes) { + return errors.New("hash mismatch") + } + + return nil +} diff --git a/services/uploads/services.go b/services/uploads/services.go new file mode 100644 index 0000000..b8a264f --- /dev/null +++ b/services/uploads/services.go @@ -0,0 +1,38 @@ +package uploads + +import ( + "context" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" +) + +type Service struct { + db *db.Provider + pendingDir string +} + +func New(db *db.Provider, pendingDir string) *Service { + return &Service{ + db: db, + pendingDir: pendingDir, + } +} + +func (s *Service) fetchSiteAndUser(ctx context.Context) (models.Site, models.User, error) { + user, ok := models.GetUser(ctx) + if !ok { + return models.Site{}, models.User{}, models.UserRequiredError + } + + site, ok := models.GetSite(ctx) + if !ok { + return models.Site{}, models.User{}, models.SiteRequiredError + } + + if site.OwnerID != user.ID { + return models.Site{}, models.User{}, models.PermissionError + } + + return site, user, nil +} diff --git a/sql/queries/pending_uploads.sql b/sql/queries/pending_uploads.sql new file mode 100644 index 0000000..423b9fb --- /dev/null +++ b/sql/queries/pending_uploads.sql @@ -0,0 +1,14 @@ +-- name: SelectPendingUploadByGUID :one +SELECT * FROM pending_uploads WHERE guid = ? LIMIT 1; + +-- name: InsertPendingUpload :one +INSERT INTO pending_uploads ( + site_id, + guid, + user_id, + filename, + file_size, + mime_type, + upload_started_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id; \ No newline at end of file diff --git a/sql/queries/uploads.sql b/sql/queries/uploads.sql new file mode 100644 index 0000000..2c37525 --- /dev/null +++ b/sql/queries/uploads.sql @@ -0,0 +1,22 @@ +-- name: SelectUploadsOfSite :many +SELECT * FROM uploads WHERE site_id = ? ORDER BY created_at DESC; + +-- name: SelectUploadByID :one +SELECT * FROM uploads WHERE id = ?; + +-- name: InsertUpload :exec +INSERT INTO uploads ( + site_id, + guid, + mime_type, + filename, + created_at, + alt +) VALUES (?, ?, ?, ?, ?, ?) +RETURNING id; + +-- name: UpdateUpload :exec +UPDATE uploads SET alt = ? WHERE id = ?; + +-- name: DeleteUpload :exec +DELETE FROM uploads WHERE id = ?; \ No newline at end of file diff --git a/sql/schema/02_upload.up.sql b/sql/schema/02_upload.up.sql new file mode 100644 index 0000000..8dfec03 --- /dev/null +++ b/sql/schema/02_upload.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE uploads ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL, + guid TEXT NOT NULL, + mime_type TEXT NOT NULL, + filename TEXT NOT NULL, + alt TEXT NOT NULL, + created_at INT NOT NULL, + + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); +CREATE INDEX idx_uploads_site ON uploads (site_id); +CREATE UNIQUE INDEX idx_uploads_guid ON sites (guid); + +CREATE TABLE pending_uploads ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL, + guid TEXT NOT NULL, + user_id INT NOT NULL, + filename TEXT NOT NULL, + file_size INT NOT NULL, + mime_type TEXT NOT NULL, + upload_started_at INT NOT NULL, + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX idx_pending_uploads_guid ON pending_uploads (guid); \ No newline at end of file diff --git a/views/uploads/index.html b/views/uploads/index.html new file mode 100644 index 0000000..4bc9627 --- /dev/null +++ b/views/uploads/index.html @@ -0,0 +1,6 @@ +
+
+ +
+
\ No newline at end of file -- 2.43.0 From 0a9af9cde8ec5e8c7935fe2aa564626851c0611b Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 2 Mar 2026 21:10:09 +1100 Subject: [PATCH 2/6] Started a repository of the uploads --- models/uploads.go | 15 ++++---- providers/db/gen/sqlgen/models.go | 1 + .../db/gen/sqlgen/pending_uploads.sql.go | 9 +++++ providers/db/gen/sqlgen/uploads.sql.go | 6 ++-- providers/db/uploads.go | 8 +++-- providers/uploadfiles/provider.go | 35 +++++++++++++++++++ services/services.go | 5 ++- services/uploads/pending.go | 26 +++++++++++++- services/uploads/services.go | 5 ++- sql/queries/pending_uploads.sql | 5 ++- sql/schema/02_upload.up.sql | 1 + 11 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 providers/uploadfiles/provider.go diff --git a/models/uploads.go b/models/uploads.go index 0e818c5..995de60 100644 --- a/models/uploads.go +++ b/models/uploads.go @@ -3,13 +3,14 @@ package models import "time" type Upload struct { - ID int64 `json:"id"` - SiteID int64 `json:"site_id"` - GUID string `json:"guid"` - MIMEType string `json:"mime_type"` - Filename string `json:"filename"` - CreatedAt int64 `json:"created_at"` - Alt string `json:"alt"` + ID int64 `json:"id"` + SiteID int64 `json:"site_id"` + GUID string `json:"guid"` + FileSize int64 `json:"file_size"` + MIMEType string `json:"mime_type"` + Filename string `json:"filename"` + CreatedAt time.Time `json:"created_at"` + Alt string `json:"alt"` } type PendingUpload struct { diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 6aed257..5b22452 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -55,6 +55,7 @@ type Upload struct { Guid string MimeType string Filename string + FileSize int64 Alt string CreatedAt int64 } diff --git a/providers/db/gen/sqlgen/pending_uploads.sql.go b/providers/db/gen/sqlgen/pending_uploads.sql.go index c2ca66b..96d8c3b 100644 --- a/providers/db/gen/sqlgen/pending_uploads.sql.go +++ b/providers/db/gen/sqlgen/pending_uploads.sql.go @@ -9,6 +9,15 @@ import ( "context" ) +const deletePendingUpload = `-- name: DeletePendingUpload :exec +DELETE FROM pending_uploads WHERE guid = ? +` + +func (q *Queries) DeletePendingUpload(ctx context.Context, guid string) error { + _, err := q.db.ExecContext(ctx, deletePendingUpload, guid) + return err +} + const insertPendingUpload = `-- name: InsertPendingUpload :one INSERT INTO pending_uploads ( site_id, diff --git a/providers/db/gen/sqlgen/uploads.sql.go b/providers/db/gen/sqlgen/uploads.sql.go index 6891422..6173374 100644 --- a/providers/db/gen/sqlgen/uploads.sql.go +++ b/providers/db/gen/sqlgen/uploads.sql.go @@ -52,7 +52,7 @@ func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) erro } const selectUploadByID = `-- name: SelectUploadByID :one -SELECT id, site_id, guid, mime_type, filename, alt, created_at FROM uploads WHERE id = ? +SELECT id, site_id, guid, mime_type, filename, file_size, alt, created_at FROM uploads WHERE id = ? ` func (q *Queries) SelectUploadByID(ctx context.Context, id interface{}) (Upload, error) { @@ -64,6 +64,7 @@ func (q *Queries) SelectUploadByID(ctx context.Context, id interface{}) (Upload, &i.Guid, &i.MimeType, &i.Filename, + &i.FileSize, &i.Alt, &i.CreatedAt, ) @@ -71,7 +72,7 @@ func (q *Queries) SelectUploadByID(ctx context.Context, id interface{}) (Upload, } const selectUploadsOfSite = `-- name: SelectUploadsOfSite :many -SELECT id, site_id, guid, mime_type, filename, alt, created_at FROM uploads WHERE site_id = ? ORDER BY created_at DESC +SELECT id, site_id, guid, mime_type, filename, file_size, alt, created_at FROM uploads WHERE site_id = ? ORDER BY created_at DESC ` func (q *Queries) SelectUploadsOfSite(ctx context.Context, siteID int64) ([]Upload, error) { @@ -89,6 +90,7 @@ func (q *Queries) SelectUploadsOfSite(ctx context.Context, siteID int64) ([]Uplo &i.Guid, &i.MimeType, &i.Filename, + &i.FileSize, &i.Alt, &i.CreatedAt, ); err != nil { diff --git a/providers/db/uploads.go b/providers/db/uploads.go index f65b794..2c7dc11 100644 --- a/providers/db/uploads.go +++ b/providers/db/uploads.go @@ -37,7 +37,7 @@ func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error Guid: upload.GUID, MimeType: upload.MIMEType, Filename: upload.Filename, - CreatedAt: upload.CreatedAt, + CreatedAt: upload.CreatedAt.Unix(), Alt: upload.Alt, }); err != nil { return err @@ -77,6 +77,10 @@ func (db *Provider) SavePendingUpload(ctx context.Context, pending *models.Pendi return err } +func (db *Provider) DeletePendingUpload(ctx context.Context, guid string) error { + return db.queries.DeletePendingUpload(ctx, guid) +} + func dbUploadToUpload(row sqlgen.Upload) models.Upload { var id int64 if idVal, ok := row.ID.(int64); ok { @@ -90,7 +94,7 @@ func dbUploadToUpload(row sqlgen.Upload) models.Upload { MIMEType: row.MimeType, Filename: row.Filename, Alt: row.Alt, - CreatedAt: row.CreatedAt, + CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), } } diff --git a/providers/uploadfiles/provider.go b/providers/uploadfiles/provider.go new file mode 100644 index 0000000..ea9698d --- /dev/null +++ b/providers/uploadfiles/provider.go @@ -0,0 +1,35 @@ +package uploadfiles + +import ( + "fmt" + "os" + "path/filepath" + + "lmika.dev/lmika/weiro/models" +) + +type Provider struct { + baseDir string +} + +func New(baseDir string) *Provider { + return &Provider{ + baseDir: baseDir, + } +} + +func (p *Provider) AdoptFile(site models.Site, up models.Upload, filename string) error { + baseDir := filepath.Join(p.baseDir, site.GUID, + fmt.Sprintf("%04d", up.CreatedAt.Year()), + fmt.Sprintf("%02d", up.CreatedAt.Month())) + + if err := os.MkdirAll(baseDir, 0755); err != nil { + return err + } + + targetFilename := filepath.Join(baseDir, up.GUID) + if err := os.Rename(filename, targetFilename); err != nil { + return err + } + return nil +} diff --git a/services/services.go b/services/services.go index 7bfd120..78e6a0e 100644 --- a/services/services.go +++ b/services/services.go @@ -5,6 +5,7 @@ import ( "lmika.dev/lmika/weiro/config" "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/providers/uploadfiles" "lmika.dev/lmika/weiro/services/auth" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" @@ -28,12 +29,14 @@ func New(cfg config.Config) (*Services, error) { return nil, err } + ufp := uploadfiles.New(filepath.Join(cfg.DataDir, "uploads")) + authSvc := auth.New(dbp) publisherSvc := publisher.New(dbp) publisherQueue := publisher.NewQueue(publisherSvc) postService := posts.New(dbp, publisherQueue) siteService := sites.New(dbp) - uploadService := uploads.New(dbp, filepath.Join(cfg.ScratchDir, "uploads", "pending")) + uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending")) return &Services{ DB: dbp, diff --git a/services/uploads/pending.go b/services/uploads/pending.go index a6dd5d3..1745610 100644 --- a/services/uploads/pending.go +++ b/services/uploads/pending.go @@ -95,12 +95,36 @@ func (s *Service) FinalizePending(ctx context.Context, pendingGUID string, expec return errors.New("invalid pending upload") } + pendingDataFilename := filepath.Join(s.pendingDir, pu.GUID+".upload") + if err := s.verifyPendingUpload(pendingDataFilename, expectedHash); err != nil { + return err + } + + newUpload := models.Upload{ + SiteID: site.ID, + GUID: models.NewNanoID(), + FileSize: pu.FileSize, + MIMEType: pu.MIMEType, + Filename: pu.Filename, + CreatedAt: time.Now().UTC(), + } + if err := s.db.SaveUpload(ctx, &newUpload); err != nil { + return err + } + + if err := s.up.AdoptFile(site, newUpload, pendingDataFilename); err != nil { + return err + } + + return nil +} + +func (s *Service) verifyPendingUpload(pendingDataFilename string, expectedHash string) error { expectedHashBytes, err := hex.DecodeString(expectedHash) if err != nil { return err } - pendingDataFilename := filepath.Join(s.pendingDir, pu.GUID+".upload") if _, err := os.Stat(pendingDataFilename); err != nil { return err } diff --git a/services/uploads/services.go b/services/uploads/services.go index b8a264f..dc24d03 100644 --- a/services/uploads/services.go +++ b/services/uploads/services.go @@ -5,16 +5,19 @@ import ( "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/providers/uploadfiles" ) type Service struct { db *db.Provider + up *uploadfiles.Provider pendingDir string } -func New(db *db.Provider, pendingDir string) *Service { +func New(db *db.Provider, up *uploadfiles.Provider, pendingDir string) *Service { return &Service{ db: db, + up: up, pendingDir: pendingDir, } } diff --git a/sql/queries/pending_uploads.sql b/sql/queries/pending_uploads.sql index 423b9fb..abb3241 100644 --- a/sql/queries/pending_uploads.sql +++ b/sql/queries/pending_uploads.sql @@ -11,4 +11,7 @@ INSERT INTO pending_uploads ( mime_type, upload_started_at ) VALUES (?, ?, ?, ?, ?, ?, ?) -RETURNING id; \ No newline at end of file +RETURNING id; + +-- name: DeletePendingUpload :exec +DELETE FROM pending_uploads WHERE guid = ?; \ No newline at end of file diff --git a/sql/schema/02_upload.up.sql b/sql/schema/02_upload.up.sql index 8dfec03..f87d92b 100644 --- a/sql/schema/02_upload.up.sql +++ b/sql/schema/02_upload.up.sql @@ -4,6 +4,7 @@ CREATE TABLE uploads ( guid TEXT NOT NULL, mime_type TEXT NOT NULL, filename TEXT NOT NULL, + file_size INT NOT NULL, alt TEXT NOT NULL, created_at INT NOT NULL, -- 2.43.0 From 48f39133d73eff4e39ec4eee36fa0dd8be424757 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 2 Mar 2026 22:26:40 +1100 Subject: [PATCH 3/6] Started working on the view upload page --- assets/js/controllers/upload.js | 1 + cmds/server.go | 2 + go.mod | 2 + go.sum | 6 ++ handlers/uploads.go | 67 ++++++++++++++++- models/uploads.go | 1 + providers/db/gen/sqlgen/models.go | 5 +- .../db/gen/sqlgen/pending_uploads.sql.go | 4 +- providers/db/gen/sqlgen/uploads.sql.go | 54 ++++++++++--- providers/db/uploads.go | 25 +++++-- providers/uploadfiles/exif.go | 32 ++++++++ providers/uploadfiles/provider.go | 19 +++-- services/uploads/manage.go | 75 +++++++++++++++++++ services/uploads/pending.go | 19 ++++- sql/queries/uploads.sql | 13 +++- sql/schema/02_upload.up.sql | 25 ++++--- views/uploads/index.html | 14 ++++ views/uploads/show.html | 8 ++ 18 files changed, 327 insertions(+), 45 deletions(-) create mode 100644 providers/uploadfiles/exif.go create mode 100644 services/uploads/manage.go create mode 100644 views/uploads/show.html diff --git a/assets/js/controllers/upload.js b/assets/js/controllers/upload.js index a810d41..e2953a3 100644 --- a/assets/js/controllers/upload.js +++ b/assets/js/controllers/upload.js @@ -33,6 +33,7 @@ export default class UploadController extends Controller { for (let file of files) { await this._doUpload(file); } + window.location.reload(); } async _doUpload(file) { diff --git a/cmds/server.go b/cmds/server.go index e12d265..92ac2ba 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -124,6 +124,8 @@ Starting weiro without any arguments will start the server. siteGroup.Delete("/posts/:postID", ph.Delete) siteGroup.Get("/uploads", uh.Index) + siteGroup.Get("/uploads/:uploadID", uh.Show) + siteGroup.Get("/uploads/:uploadID/raw", uh.ShowRaw) siteGroup.Post("/uploads/pending", uh.New) siteGroup.Post("/uploads/pending/:guid", uh.UploadPart) siteGroup.Post("/uploads/pending/:guid/finalize", uh.UploadComplete) diff --git a/go.mod b/go.mod index c6e73c1..2afe966 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect + github.com/barasher/go-exiftool v1.10.0 // indirect github.com/cenkalti/backoff/v4 v4.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect @@ -80,6 +81,7 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + lmika.dev/pkg/modash v0.1.1-0.20260302110707-31c6b125c997 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 6f783dd..7138969 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:o github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGLmAjMPwCCCo7Jf0W6f9slllCkkv7vyc1yOSg= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48= +github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs= +github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= @@ -731,6 +733,10 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= lmika.dev/pkg/litemigrate v0.1.0 h1:DBEJahbQO7W3uEmAOQGg1URBWYimg0ClWHi83M2MZwk= lmika.dev/pkg/litemigrate v0.1.0/go.mod h1:GQWWDiMZGQaVspcwKNq8vIBPN5H+KsUo/VBIeh9OfLg= +lmika.dev/pkg/modash v0.1.0 h1:fltroSvP0nKj9K0E6G+S9LULvB9Qhj47+SZ2b9v/v/c= +lmika.dev/pkg/modash v0.1.0/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= +lmika.dev/pkg/modash v0.1.1-0.20260302110707-31c6b125c997 h1:XGdi5Ca5IJgGXPd057R2QHENQ6PwIUUfhBTGGF6yuLM= +lmika.dev/pkg/modash v0.1.1-0.20260302110707-31c6b125c997/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= diff --git a/handlers/uploads.go b/handlers/uploads.go index 3d6e891..6b60313 100644 --- a/handlers/uploads.go +++ b/handlers/uploads.go @@ -1,8 +1,15 @@ package handlers import ( + "bufio" + "io" + "log" + "net/http" + "strconv" + "github.com/gofiber/fiber/v3" "lmika.dev/lmika/weiro/services/uploads" + "lmika.dev/pkg/modash/moslice" ) type UploadsHandler struct { @@ -10,7 +17,65 @@ type UploadsHandler struct { } func (uh UploadsHandler) Index(c fiber.Ctx) error { - return c.Render("uploads/index", nil) + uploads, err := uh.UploadsService.ListUploads(c.Context()) + if err != nil { + return err + } + + rows := moslice.Batch(uploads, 5) + + return c.Render("uploads/index", fiber.Map{"uploads": rows}) +} + +func (uh UploadsHandler) Show(c fiber.Ctx) error { + uploadIDStr := c.Params("uploadID") + if uploadIDStr == "" { + return fiber.ErrBadRequest + } + uploadID, err := strconv.ParseInt(uploadIDStr, 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + upload, err := uh.UploadsService.FetchUpload(c.Context(), uploadID) + if err != nil { + return err + } + + return c.Render("uploads/show", fiber.Map{"upload": upload}) +} + +func (uh UploadsHandler) ShowRaw(c fiber.Ctx) error { + uploadIDStr := c.Params("uploadID") + if uploadIDStr == "" { + return fiber.ErrBadRequest + } + uploadID, err := strconv.ParseInt(uploadIDStr, 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + upload, rwFn, err := uh.UploadsService.OpenUpload(c.Context(), uploadID) + if err != nil { + log.Print(err) + return err + } + + c.Set("Content-Type", upload.MIMEType) + c.Status(http.StatusOK) + return c.SendStreamWriter(func(w *bufio.Writer) { + rw, err := rwFn() + if err != nil { + return + } + defer rw.Close() + + _, err = io.Copy(w, rw) + if err != nil { + return + } + }) + } func (uh UploadsHandler) New(c fiber.Ctx) error { diff --git a/models/uploads.go b/models/uploads.go index 995de60..110f639 100644 --- a/models/uploads.go +++ b/models/uploads.go @@ -10,6 +10,7 @@ type Upload struct { MIMEType string `json:"mime_type"` Filename string `json:"filename"` CreatedAt time.Time `json:"created_at"` + Slug string `json:"slug"` Alt string `json:"alt"` } diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 5b22452..1de5ce3 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -5,7 +5,7 @@ package sqlgen type PendingUpload struct { - ID interface{} + ID int64 SiteID int64 Guid string UserID int64 @@ -50,12 +50,13 @@ type Site struct { } type Upload struct { - ID interface{} + ID int64 SiteID int64 Guid string MimeType string Filename string FileSize int64 + Slug string Alt string CreatedAt int64 } diff --git a/providers/db/gen/sqlgen/pending_uploads.sql.go b/providers/db/gen/sqlgen/pending_uploads.sql.go index 96d8c3b..a831bbe 100644 --- a/providers/db/gen/sqlgen/pending_uploads.sql.go +++ b/providers/db/gen/sqlgen/pending_uploads.sql.go @@ -41,7 +41,7 @@ type InsertPendingUploadParams struct { UploadStartedAt int64 } -func (q *Queries) InsertPendingUpload(ctx context.Context, arg InsertPendingUploadParams) (interface{}, error) { +func (q *Queries) InsertPendingUpload(ctx context.Context, arg InsertPendingUploadParams) (int64, error) { row := q.db.QueryRowContext(ctx, insertPendingUpload, arg.SiteID, arg.Guid, @@ -51,7 +51,7 @@ func (q *Queries) InsertPendingUpload(ctx context.Context, arg InsertPendingUplo arg.MimeType, arg.UploadStartedAt, ) - var id interface{} + var id int64 err := row.Scan(&id) return id, err } diff --git a/providers/db/gen/sqlgen/uploads.sql.go b/providers/db/gen/sqlgen/uploads.sql.go index 6173374..0433ae9 100644 --- a/providers/db/gen/sqlgen/uploads.sql.go +++ b/providers/db/gen/sqlgen/uploads.sql.go @@ -13,7 +13,7 @@ const deleteUpload = `-- name: DeleteUpload :exec DELETE FROM uploads WHERE id = ? ` -func (q *Queries) DeleteUpload(ctx context.Context, id interface{}) error { +func (q *Queries) DeleteUpload(ctx context.Context, id int64) error { _, err := q.db.ExecContext(ctx, deleteUpload, id) return err } @@ -24,9 +24,11 @@ INSERT INTO uploads ( guid, mime_type, filename, - created_at, - alt -) VALUES (?, ?, ?, ?, ?, ?) + file_size, + slug, + alt, + created_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id ` @@ -35,8 +37,10 @@ type InsertUploadParams struct { Guid string MimeType string Filename string - CreatedAt int64 + FileSize int64 + Slug string Alt string + CreatedAt int64 } func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) error { @@ -45,17 +49,19 @@ func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) erro arg.Guid, arg.MimeType, arg.Filename, - arg.CreatedAt, + arg.FileSize, + arg.Slug, arg.Alt, + arg.CreatedAt, ) return err } const selectUploadByID = `-- name: SelectUploadByID :one -SELECT id, site_id, guid, mime_type, filename, file_size, alt, created_at FROM uploads WHERE id = ? +SELECT id, site_id, guid, mime_type, filename, file_size, slug, alt, created_at FROM uploads WHERE id = ? LIMIT 1 ` -func (q *Queries) SelectUploadByID(ctx context.Context, id interface{}) (Upload, error) { +func (q *Queries) SelectUploadByID(ctx context.Context, id int64) (Upload, error) { row := q.db.QueryRowContext(ctx, selectUploadByID, id) var i Upload err := row.Scan( @@ -65,6 +71,33 @@ func (q *Queries) SelectUploadByID(ctx context.Context, id interface{}) (Upload, &i.MimeType, &i.Filename, &i.FileSize, + &i.Slug, + &i.Alt, + &i.CreatedAt, + ) + return i, err +} + +const selectUploadBySiteIDAndSlug = `-- name: SelectUploadBySiteIDAndSlug :one +SELECT id, site_id, guid, mime_type, filename, file_size, slug, alt, created_at FROM uploads WHERE site_id = ? AND slug = ? LIMIT 1 +` + +type SelectUploadBySiteIDAndSlugParams struct { + SiteID int64 + Slug string +} + +func (q *Queries) SelectUploadBySiteIDAndSlug(ctx context.Context, arg SelectUploadBySiteIDAndSlugParams) (Upload, error) { + row := q.db.QueryRowContext(ctx, selectUploadBySiteIDAndSlug, arg.SiteID, arg.Slug) + var i Upload + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.MimeType, + &i.Filename, + &i.FileSize, + &i.Slug, &i.Alt, &i.CreatedAt, ) @@ -72,7 +105,7 @@ func (q *Queries) SelectUploadByID(ctx context.Context, id interface{}) (Upload, } const selectUploadsOfSite = `-- name: SelectUploadsOfSite :many -SELECT id, site_id, guid, mime_type, filename, file_size, alt, created_at FROM uploads WHERE site_id = ? ORDER BY created_at DESC +SELECT id, site_id, guid, mime_type, filename, file_size, slug, alt, created_at FROM uploads WHERE site_id = ? ORDER BY created_at DESC ` func (q *Queries) SelectUploadsOfSite(ctx context.Context, siteID int64) ([]Upload, error) { @@ -91,6 +124,7 @@ func (q *Queries) SelectUploadsOfSite(ctx context.Context, siteID int64) ([]Uplo &i.MimeType, &i.Filename, &i.FileSize, + &i.Slug, &i.Alt, &i.CreatedAt, ); err != nil { @@ -113,7 +147,7 @@ UPDATE uploads SET alt = ? WHERE id = ? type UpdateUploadParams struct { Alt string - ID interface{} + ID int64 } func (q *Queries) UpdateUpload(ctx context.Context, arg UpdateUploadParams) error { diff --git a/providers/db/uploads.go b/providers/db/uploads.go index 2c7dc11..006b7cc 100644 --- a/providers/db/uploads.go +++ b/providers/db/uploads.go @@ -30,6 +30,18 @@ func (db *Provider) SelectUploadsOfSite(ctx context.Context, siteID int64) ([]mo return uploads, nil } +func (db *Provider) SelectUploadBySiteIDAndSlug(ctx context.Context, siteID int64, slug string) (models.Upload, error) { + row, err := db.queries.SelectUploadBySiteIDAndSlug(ctx, sqlgen.SelectUploadBySiteIDAndSlugParams{ + SiteID: siteID, + Slug: slug, + }) + if err != nil { + return models.Upload{}, err + } + + return dbUploadToUpload(row), nil +} + func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error { if upload.ID == 0 { if err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{ @@ -37,8 +49,10 @@ func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error Guid: upload.GUID, MimeType: upload.MIMEType, Filename: upload.Filename, - CreatedAt: upload.CreatedAt.Unix(), + FileSize: upload.FileSize, + Slug: upload.Slug, Alt: upload.Alt, + CreatedAt: upload.CreatedAt.Unix(), }); err != nil { return err } @@ -82,17 +96,14 @@ func (db *Provider) DeletePendingUpload(ctx context.Context, guid string) error } func dbUploadToUpload(row sqlgen.Upload) models.Upload { - var id int64 - if idVal, ok := row.ID.(int64); ok { - id = idVal - } - return models.Upload{ - ID: id, + ID: row.ID, SiteID: row.SiteID, GUID: row.Guid, MIMEType: row.MimeType, + FileSize: row.FileSize, Filename: row.Filename, + Slug: row.Slug, Alt: row.Alt, CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), } diff --git a/providers/uploadfiles/exif.go b/providers/uploadfiles/exif.go new file mode 100644 index 0000000..a6f2ae7 --- /dev/null +++ b/providers/uploadfiles/exif.go @@ -0,0 +1,32 @@ +package uploadfiles + +import ( + "emperror.dev/errors" + "github.com/barasher/go-exiftool" + "lmika.dev/lmika/weiro/models" +) + +func (p *Provider) StripeEXIFData(site models.Site, up models.Upload) error { + uploadFilename := p.uploadFileName(site, up) + + et, err := exiftool.NewExiftool() + if err != nil { + return err + } + defer et.Close() + + fileInfos := et.ExtractMetadata(uploadFilename) + if len(fileInfos) == 0 { + return errors.New("no exif data found") + } + fileInfo := fileInfos[0] + fileInfo.ClearAll() + + fileOut := []exiftool.FileMetadata{fileInfo} + et.WriteMetadata(fileOut) + if fileOut[0].Err != nil { + return fileOut[0].Err + } + + return nil +} diff --git a/providers/uploadfiles/provider.go b/providers/uploadfiles/provider.go index ea9698d..bbc5d44 100644 --- a/providers/uploadfiles/provider.go +++ b/providers/uploadfiles/provider.go @@ -1,7 +1,7 @@ package uploadfiles import ( - "fmt" + "io" "os" "path/filepath" @@ -19,17 +19,24 @@ func New(baseDir string) *Provider { } func (p *Provider) AdoptFile(site models.Site, up models.Upload, filename string) error { - baseDir := filepath.Join(p.baseDir, site.GUID, - fmt.Sprintf("%04d", up.CreatedAt.Year()), - fmt.Sprintf("%02d", up.CreatedAt.Month())) + fullPath := p.uploadFileName(site, up) + baseDir := filepath.Dir(fullPath) if err := os.MkdirAll(baseDir, 0755); err != nil { return err } - targetFilename := filepath.Join(baseDir, up.GUID) - if err := os.Rename(filename, targetFilename); err != nil { + if err := os.Rename(filename, fullPath); err != nil { return err } return nil } + +func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser, error) { + fullPath := p.uploadFileName(site, up) + return os.Open(fullPath) +} + +func (p *Provider) uploadFileName(site models.Site, up models.Upload) string { + return filepath.Join(p.baseDir, site.GUID, up.Slug) +} diff --git a/services/uploads/manage.go b/services/uploads/manage.go new file mode 100644 index 0000000..3bfe4e7 --- /dev/null +++ b/services/uploads/manage.go @@ -0,0 +1,75 @@ +package uploads + +import ( + "context" + "fmt" + "io" + + "lmika.dev/lmika/weiro/models" +) + +type UploadWithURL struct { + Upload models.Upload + URL string +} + +func (s *Service) FetchUpload(ctx context.Context, uploadID int64) (res UploadWithURL, _ error) { + site, _, err := s.fetchSiteAndUser(ctx) + if err != nil { + return UploadWithURL{}, err + } + + upload, err := s.db.SelectUploadByID(ctx, uploadID) + if err != nil { + return UploadWithURL{}, err + } + + return UploadWithURL{ + Upload: upload, + URL: fmt.Sprintf("/sites/%v/uploads/%v/raw", site.ID, upload.ID), + }, nil +} + +func (s *Service) ListUploads(ctx context.Context) (res []UploadWithURL, _ error) { + site, _, err := s.fetchSiteAndUser(ctx) + if err != nil { + return nil, err + } + + uploads, err := s.db.SelectUploadsOfSite(ctx, site.ID) + if err != nil { + return nil, err + } + + res = make([]UploadWithURL, len(uploads)) + for i, upload := range uploads { + res[i] = UploadWithURL{ + Upload: upload, + URL: fmt.Sprintf("/sites/%v/uploads/%v/raw", site.ID, upload.ID), + } + } + + return res, nil +} + +func (s *Service) OpenUpload(ctx context.Context, id int64) (models.Upload, func() (io.ReadCloser, error), error) { + site, _, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.Upload{}, nil, err + } + + upload, err := s.db.SelectUploadByID(ctx, id) + if err != nil { + return models.Upload{}, nil, err + } else if upload.SiteID != site.ID { + return models.Upload{}, nil, models.NotFoundError + } + + return upload, func() (io.ReadCloser, error) { + rw, err := s.up.OpenUpload(site, upload) + if err != nil { + return nil, err + } + return rw, nil + }, nil +} diff --git a/services/uploads/pending.go b/services/uploads/pending.go index 1745610..0e281c3 100644 --- a/services/uploads/pending.go +++ b/services/uploads/pending.go @@ -5,7 +5,9 @@ import ( "context" "crypto/sha256" "encoding/hex" + "fmt" "io" + "log" "os" "path/filepath" "time" @@ -100,13 +102,22 @@ func (s *Service) FinalizePending(ctx context.Context, pendingGUID string, expec return err } + newUploadGUID := models.NewNanoID() + newTime := time.Now().UTC() + newSlug := filepath.Join( + fmt.Sprintf("%04d", newTime.Year()), + fmt.Sprintf("%02d", newTime.Month()), + newUploadGUID+filepath.Ext(pu.Filename), + ) + newUpload := models.Upload{ SiteID: site.ID, GUID: models.NewNanoID(), FileSize: pu.FileSize, MIMEType: pu.MIMEType, Filename: pu.Filename, - CreatedAt: time.Now().UTC(), + CreatedAt: newTime, + Slug: newSlug, } if err := s.db.SaveUpload(ctx, &newUpload); err != nil { return err @@ -115,6 +126,12 @@ func (s *Service) FinalizePending(ctx context.Context, pendingGUID string, expec if err := s.up.AdoptFile(site, newUpload, pendingDataFilename); err != nil { return err } + if err := s.db.DeletePendingUpload(ctx, newUpload.GUID); err != nil { + return err + } + if err := s.up.StripeEXIFData(site, newUpload); err != nil { + log.Printf("warn: failed to extract exif data from %s: %v\n", newUpload.Slug, err) + } return nil } diff --git a/sql/queries/uploads.sql b/sql/queries/uploads.sql index 2c37525..fc8b82d 100644 --- a/sql/queries/uploads.sql +++ b/sql/queries/uploads.sql @@ -2,7 +2,10 @@ SELECT * FROM uploads WHERE site_id = ? ORDER BY created_at DESC; -- name: SelectUploadByID :one -SELECT * FROM uploads WHERE id = ?; +SELECT * FROM uploads WHERE id = ? LIMIT 1; + +-- name: SelectUploadBySiteIDAndSlug :one +SELECT * FROM uploads WHERE site_id = ? AND slug = ? LIMIT 1; -- name: InsertUpload :exec INSERT INTO uploads ( @@ -10,9 +13,11 @@ INSERT INTO uploads ( guid, mime_type, filename, - created_at, - alt -) VALUES (?, ?, ?, ?, ?, ?) + file_size, + slug, + alt, + created_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id; -- name: UpdateUpload :exec diff --git a/sql/schema/02_upload.up.sql b/sql/schema/02_upload.up.sql index f87d92b..b0bba74 100644 --- a/sql/schema/02_upload.up.sql +++ b/sql/schema/02_upload.up.sql @@ -1,27 +1,28 @@ CREATE TABLE uploads ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INT NOT NULL, guid TEXT NOT NULL, mime_type TEXT NOT NULL, filename TEXT NOT NULL, - file_size INT NOT NULL, + file_size INT NOT NULL, + slug TEXT NOT NULL, alt TEXT NOT NULL, created_at INT NOT NULL, - FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE ); CREATE INDEX idx_uploads_site ON uploads (site_id); -CREATE UNIQUE INDEX idx_uploads_guid ON sites (guid); +CREATE UNIQUE INDEX idx_uploads_guid ON uploads (guid); +CREATE UNIQUE INDEX idx_uploads_site_slug ON uploads (site_id, slug); CREATE TABLE pending_uploads ( - id SERIAL PRIMARY KEY, - site_id INT NOT NULL, - guid TEXT NOT NULL, - user_id INT NOT NULL, - filename TEXT NOT NULL, - file_size INT NOT NULL, - mime_type TEXT NOT NULL, - upload_started_at INT NOT NULL, + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INT NOT NULL, + guid TEXT NOT NULL, + user_id INT NOT NULL, + filename TEXT NOT NULL, + file_size INT NOT NULL, + mime_type TEXT NOT NULL, + upload_started_at INT NOT NULL, FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); diff --git a/views/uploads/index.html b/views/uploads/index.html index 4bc9627..a510c99 100644 --- a/views/uploads/index.html +++ b/views/uploads/index.html @@ -3,4 +3,18 @@ data-controller="upload" data-upload-site-id-value="{{ .site.ID }}"> + + {{ range .uploads }} +
+ {{ range . }} + + {{ end }} +
+ {{ end }} \ No newline at end of file diff --git a/views/uploads/show.html b/views/uploads/show.html new file mode 100644 index 0000000..979b11e --- /dev/null +++ b/views/uploads/show.html @@ -0,0 +1,8 @@ +
+
+ +
+ + {{ .Upload.Alt }} +
\ No newline at end of file -- 2.43.0 From d0cebe6564fb135556ceb39246616840d482629c Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 3 Mar 2026 22:36:24 +1100 Subject: [PATCH 4/6] Added publishing of uploads to built site --- .gitignore | 1 + Makefile | 8 +++- assets/js/controllers/show_upload.js | 19 +++++++++ assets/js/main.js | 4 +- go.mod | 1 + go.sum | 2 + models/pubmodel/sites.go | 9 +++- providers/sitebuilder/builder.go | 61 +++++++++++++++++++++++++--- providers/uploadfiles/provider.go | 4 ++ services/publisher/service.go | 16 +++++++- services/services.go | 2 +- services/uploads/manage.go | 32 ++++++++++++--- views/uploads/show.html | 6 +-- 13 files changed, 145 insertions(+), 20 deletions(-) create mode 100644 assets/js/controllers/show_upload.js diff --git a/.gitignore b/.gitignore index c8b64a2..e2b5d01 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ static/assets/ # Local Netlify folder .netlify .env +.DS_Store diff --git a/Makefile b/Makefile index bbbe928..4c9bbae 100644 --- a/Makefile +++ b/Makefile @@ -25,4 +25,10 @@ build: frontend .Phony: run run: build - ./build/weiro \ No newline at end of file + ./build/weiro + +.Phony: setup_targets +setup_targets: build + SITE_ID=$$(DATA_DIR=$(BUILD_DIR)/data ./$(BUILD_DIR)/weiro sites | tail -n1 | awk '{ print $$1 }'); \ + DATA_DIR=$(BUILD_DIR)/data ./build/weiro pubtargets "$$SITE_ID"; \ + DATA_DIR=$(BUILD_DIR)/data ./build/weiro pubtargets add --site "$$SITE_ID" --type localfs --ref ./$(BUILD_DIR)/out --url http://localhost:8000 diff --git a/assets/js/controllers/show_upload.js b/assets/js/controllers/show_upload.js new file mode 100644 index 0000000..65f34bc --- /dev/null +++ b/assets/js/controllers/show_upload.js @@ -0,0 +1,19 @@ +import { Controller } from "@hotwired/stimulus" +import {showToast} from "../services/toast"; + +export default class ShowUploadController extends Controller { + static values = { + copySnippet: String, + }; + + async copy(ev) { + ev.preventDefault(); + + await navigator.clipboard.writeText(this.copySnippetValue); + + showToast({ + title: "️📋 HTML Snippet", + body: "Copied to clipboard.", + }); + } +} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index 3e8c345..d76c353 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -6,6 +6,7 @@ import PosteditController from "./controllers/postedit"; import LogoutController from "./controllers/logout"; import FirstRunController from "./controllers/firstrun"; import UploadController from "./controllers/upload"; +import ShowUploadController from "./controllers/show_upload"; window.Stimulus = Application.start() Stimulus.register("toast", ToastController); @@ -13,4 +14,5 @@ Stimulus.register("postlist", PostlistController); Stimulus.register("postedit", PosteditController); Stimulus.register("logout", LogoutController); Stimulus.register("first-run", FirstRunController); -Stimulus.register("upload", UploadController); \ No newline at end of file +Stimulus.register("upload", UploadController); +Stimulus.register("show-upload", ShowUploadController); \ No newline at end of file diff --git a/go.mod b/go.mod index 2afe966..35a2797 100644 --- a/go.mod +++ b/go.mod @@ -78,6 +78,7 @@ require ( golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 7138969..faa9baf 100644 --- a/go.sum +++ b/go.sum @@ -581,6 +581,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/models/pubmodel/sites.go b/models/pubmodel/sites.go index 74e7444..6fc3942 100644 --- a/models/pubmodel/sites.go +++ b/models/pubmodel/sites.go @@ -1,9 +1,16 @@ package pubmodel -import "lmika.dev/lmika/weiro/models" +import ( + "io" + + "lmika.dev/lmika/weiro/models" +) type Site struct { models.Site BaseURL string Posts []*models.Post + Uploads []models.Upload + + OpenUpload func(u models.Upload) (io.ReadCloser, error) } diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index b07ea79..90e8610 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -5,7 +5,6 @@ import ( "fmt" "html/template" "io" - "log" "os" "path/filepath" "sort" @@ -15,6 +14,7 @@ import ( "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" + "golang.org/x/sync/errgroup" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/models/pubmodel" ) @@ -57,13 +57,32 @@ func (b *Builder) BuildSite(outDir string) error { return err } - for _, post := range b.site.Posts { - if err := b.writePost(buildCtx, post); err != nil { + eg := errgroup.Group{} + + eg.Go(func() error { + for _, post := range b.site.Posts { + if err := b.writePost(buildCtx, post); err != nil { + return err + } + } + return nil + }) + + eg.Go(func() error { + if err := b.renderPostList(buildCtx, b.site.Posts); err != nil { return err } - } + return nil + }) - if err := b.renderPostList(buildCtx, b.site.Posts); err != nil { + // Copy uploads + eg.Go(func() error { + if err := b.writeUploads(buildCtx, b.site.Uploads); err != nil { + return err + } + return nil + }) + if err := eg.Wait(); err != nil { return err } @@ -130,7 +149,6 @@ func (b *Builder) createAtPath(ctx buildContext, path string, fn func(f io.Write if filepath.Ext(outFile) == "" { outFile = filepath.Join(outFile, "index.html") } - log.Printf("Writing %s\n", outFile) // Render it within the template if err := os.MkdirAll(filepath.Dir(outFile), 0755); err != nil { @@ -158,6 +176,37 @@ func (b *Builder) renderTemplate(w io.Writer, name string, data interface{}) err }) } +func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error { + for _, u := range uploads { + fullPath := filepath.Join(ctx.outDir, "uploads", u.Slug) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return err + } + + if err := func() error { + r, err := b.site.OpenUpload(u) + if err != nil { + return err + } + defer r.Close() + + w, err := os.Create(fullPath) + if err != nil { + return err + } + defer w.Close() + + if _, err := io.Copy(w, r); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil +} + type buildContext struct { outDir string } diff --git a/providers/uploadfiles/provider.go b/providers/uploadfiles/provider.go index bbc5d44..b0ed4c1 100644 --- a/providers/uploadfiles/provider.go +++ b/providers/uploadfiles/provider.go @@ -37,6 +37,10 @@ func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser return os.Open(fullPath) } +func (p *Provider) UploadDir(site models.Site) string { + return filepath.Join(p.baseDir, site.GUID) +} + func (p *Provider) uploadFileName(site models.Site, up models.Upload) string { return filepath.Join(p.baseDir, site.GUID, up.Slug) } diff --git a/services/publisher/service.go b/services/publisher/service.go index 2cc47c8..7026bca 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -2,6 +2,7 @@ package publisher import ( "context" + "io" "log" "os" @@ -16,15 +17,18 @@ import ( "lmika.dev/lmika/weiro/providers/db" "lmika.dev/lmika/weiro/providers/sitebuilder" "lmika.dev/lmika/weiro/providers/siteexporter" + "lmika.dev/lmika/weiro/providers/uploadfiles" ) type Publisher struct { db *db.Provider + up *uploadfiles.Provider } -func New(db *db.Provider) *Publisher { +func New(db *db.Provider, up *uploadfiles.Provider) *Publisher { return &Publisher{ db: db, + up: up, } } @@ -40,6 +44,12 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { return err } + // Fetch all uploads of site + uploads, err := p.db.SelectUploadsOfSite(ctx, site.ID) + if err != nil { + return err + } + for _, target := range targets { if !target.Enabled { continue @@ -49,6 +59,10 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { Site: site, Posts: posts, BaseURL: target.BaseURL, + Uploads: uploads, + OpenUpload: func(u models.Upload) (io.ReadCloser, error) { + return p.up.OpenUpload(site, u) + }, } if err := p.publishSite(ctx, pubSite, target); err != nil { diff --git a/services/services.go b/services/services.go index 78e6a0e..606e932 100644 --- a/services/services.go +++ b/services/services.go @@ -32,7 +32,7 @@ func New(cfg config.Config) (*Services, error) { ufp := uploadfiles.New(filepath.Join(cfg.DataDir, "uploads")) authSvc := auth.New(dbp) - publisherSvc := publisher.New(dbp) + publisherSvc := publisher.New(dbp, ufp) publisherQueue := publisher.NewQueue(publisherSvc) postService := posts.New(dbp, publisherQueue) siteService := sites.New(dbp) diff --git a/services/uploads/manage.go b/services/uploads/manage.go index 3bfe4e7..7176910 100644 --- a/services/uploads/manage.go +++ b/services/uploads/manage.go @@ -3,14 +3,22 @@ package uploads import ( "context" "fmt" + "html/template" "io" + "log" + "strings" "lmika.dev/lmika/weiro/models" ) +var ( + uploadCopyTemplate = template.Must(template.New("upload-copy").Parse(`{{.Alt}}`)) +) + type UploadWithURL struct { - Upload models.Upload - URL string + Upload models.Upload + CopyTemplate string + URL string } func (s *Service) FetchUpload(ctx context.Context, uploadID int64) (res UploadWithURL, _ error) { @@ -25,11 +33,22 @@ func (s *Service) FetchUpload(ctx context.Context, uploadID int64) (res UploadWi } return UploadWithURL{ - Upload: upload, - URL: fmt.Sprintf("/sites/%v/uploads/%v/raw", site.ID, upload.ID), + Upload: upload, + CopyTemplate: s.renderCopyTemplate(upload), + URL: fmt.Sprintf("/sites/%v/uploads/%v/raw", site.ID, upload.ID), }, nil } +func (s *Service) renderCopyTemplate(upload models.Upload) string { + var sb strings.Builder + + if err := uploadCopyTemplate.Execute(&sb, upload); err != nil { + log.Printf("error rendering upload copy template: %v", err) + return "" + } + return sb.String() +} + func (s *Service) ListUploads(ctx context.Context) (res []UploadWithURL, _ error) { site, _, err := s.fetchSiteAndUser(ctx) if err != nil { @@ -44,8 +63,9 @@ func (s *Service) ListUploads(ctx context.Context) (res []UploadWithURL, _ error res = make([]UploadWithURL, len(uploads)) for i, upload := range uploads { res[i] = UploadWithURL{ - Upload: upload, - URL: fmt.Sprintf("/sites/%v/uploads/%v/raw", site.ID, upload.ID), + Upload: upload, + CopyTemplate: s.renderCopyTemplate(upload), + URL: fmt.Sprintf("/sites/%v/uploads/%v/raw", site.ID, upload.ID), } } diff --git a/views/uploads/show.html b/views/uploads/show.html index 979b11e..9e778e2 100644 --- a/views/uploads/show.html +++ b/views/uploads/show.html @@ -1,8 +1,8 @@
- + data-controller="show-upload" data-show-upload-copy-snippet-value="{{ .upload.CopyTemplate }}"> +
- {{ .Upload.Alt }} + {{ .upload.Upload.Alt }}
\ No newline at end of file -- 2.43.0 From 199ff9feb9dc5c27b27ef129c9db9049dbae0ff2 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 4 Mar 2026 22:33:39 +1100 Subject: [PATCH 5/6] More changes to uploads: - Have got upload images appearing in the post list - Allowed for deleting uploads - Allowed for seeing the upload progress - Fixed the setting of upload properties like the MIME type - Removed the stripe exif logic with just re-encoding PNGs and JPEGs by loading them and saving them --- assets/css/main.scss | 16 ++++++ assets/js/controllers/show_upload.js | 22 +++++++ assets/js/controllers/upload.js | 65 ++++++++++++++++++--- cmds/server.go | 26 +++++---- go.mod | 7 +++ go.sum | 53 +++++++++++++++++ handlers/uploads.go | 41 ++++++++++++- models/ctx.go | 5 ++ models/uploads.go | 2 +- providers/markdown/htmltransforms.go | 27 +++++++++ providers/markdown/renderer.go | 86 ++++++++++++++++++++++++++++ providers/markdown/uiexts.go | 21 +++++++ providers/sitebuilder/builder.go | 26 +++------ providers/uploadfiles/exif.go | 52 ++++++++++++----- providers/uploadfiles/provider.go | 11 ++++ services/uploads/manage.go | 40 +++++++++++++ services/uploads/pending.go | 4 +- views/_common/nav.html | 3 + views/posts/index.html | 7 ++- views/uploads/index.html | 8 ++- views/uploads/show.html | 14 +++-- 21 files changed, 471 insertions(+), 65 deletions(-) create mode 100644 providers/markdown/htmltransforms.go create mode 100644 providers/markdown/renderer.go create mode 100644 providers/markdown/uiexts.go diff --git a/assets/css/main.scss b/assets/css/main.scss index 9c177a6..c8f0344 100644 --- a/assets/css/main.scss +++ b/assets/css/main.scss @@ -24,4 +24,20 @@ $container-max-widths: ( .post-form textarea { height: 100%; +} + +.postlist .post img { + max-width: 300px; + height: auto; + max-height: 300px; +} + +.show-upload figure img { + max-width: 100vw; + height: auto; + max-height: 70vh; +} + +.upload-progressbar { + width: 150px; } \ No newline at end of file diff --git a/assets/js/controllers/show_upload.js b/assets/js/controllers/show_upload.js index 65f34bc..3eaf831 100644 --- a/assets/js/controllers/show_upload.js +++ b/assets/js/controllers/show_upload.js @@ -4,6 +4,8 @@ import {showToast} from "../services/toast"; export default class ShowUploadController extends Controller { static values = { copySnippet: String, + siteId: Number, + uploadId: Number, }; async copy(ev) { @@ -16,4 +18,24 @@ export default class ShowUploadController extends Controller { body: "Copied to clipboard.", }); } + + async delete(ev) { + ev.preventDefault(); + if (!confirm("Are you sure you want to delete this upload?")) { + return; + } + await this._doDelete(); + window.location = `/sites/${this.siteIdValue}/uploads/`; + } + + async _doDelete() { + const url = `/sites/${this.siteIdValue}/uploads/${this.uploadIdValue}` + + await fetch(url, { + method: 'DELETE', + headers: { + 'Accept': 'application/json' + } + }) + } } \ No newline at end of file diff --git a/assets/js/controllers/upload.js b/assets/js/controllers/upload.js index e2953a3..234ff6f 100644 --- a/assets/js/controllers/upload.js +++ b/assets/js/controllers/upload.js @@ -1,6 +1,12 @@ import { Controller } from "@hotwired/stimulus" export default class UploadController extends Controller { + static targets = [ + 'uploadBtn', + 'progressbar', + 'progressbarProgress', + ]; + static values = { siteId: Number, }; @@ -30,13 +36,17 @@ export default class UploadController extends Controller { } async _doUploads(files) { - for (let file of files) { - await this._doUpload(file); + this.uploadBtnTarget.disabled = true; + this._showUploadProgressBar(); + + for (let i = 0; i < files.length; i++) { + await this._doUpload(files[i], i, files.length); } + window.location.reload(); } - async _doUpload(file) { + async _doUpload(file, thisFileIndex, nFiles) { console.log(`Uploading ${file.name}: new pending`); // Prepare upload of file supplying size and mime-type @@ -60,12 +70,11 @@ export default class UploadController extends Controller { let chunk = file.slice(offset, offset + chunkSize); console.log(`Uploading ${file.name}: uploading part`); - await fetch(`/sites/${this.siteIdValue}/uploads/pending/${newPending.guid}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/octet-stream' - }, - body: chunk + await this._uploadChunk(`/sites/${this.siteIdValue}/uploads/pending/${newPending.guid}`, chunk, { + chunkOffset: offset, + totalSize: file.size, + thisFileIndex, + nFiles, }); offset += chunkSize; @@ -88,6 +97,44 @@ export default class UploadController extends Controller { }); } + _uploadChunk(url, chunk, progressInfo) { + let { chunkOffset, totalSize, thisFileIndex, nFiles } = progressInfo; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const bytesUploaded = chunkOffset + e.loaded; + const fractionalCompleteOfThisFile = +bytesUploaded / +totalSize; + const percentComplete = (thisFileIndex + fractionalCompleteOfThisFile) * 100 / nFiles; + console.log(`Uploading ${chunk.name}: ${percentComplete.toFixed(2)}%`); + this.progressbarProgressTarget.style.width = `${percentComplete}%`; + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + }); + + xhr.addEventListener('error', () => reject(new Error('Upload failed'))); + xhr.addEventListener('abort', () => reject(new Error('Upload aborted'))); + + xhr.open('POST', url); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.send(chunk); + }); + } + + _showUploadProgressBar() { + this.progressbarTarget.classList.remove('d-none'); + this.progressbarProgressTarget.style.width = '0%'; + } + async _calculateSHA256(file) { const arrayBuffer = await file.arrayBuffer(); const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); diff --git a/cmds/server.go b/cmds/server.go index 92ac2ba..c39f233 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -6,7 +6,6 @@ import ( "html/template" "log" "path/filepath" - "strings" "time" "github.com/gofiber/fiber/v3" @@ -17,11 +16,11 @@ import ( fiber_html "github.com/gofiber/template/html/v3" "github.com/gofiber/utils/v2" "github.com/spf13/cobra" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/extension" "lmika.dev/lmika/weiro/config" "lmika.dev/lmika/weiro/handlers" "lmika.dev/lmika/weiro/handlers/middleware" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/markdown" "lmika.dev/lmika/weiro/services" ) @@ -49,16 +48,19 @@ Starting weiro without any arguments will start the server. fiberTemplate := fiber_html.New("./views", ".html") fiberTemplate.Funcmap["sub"] = func(x, y int) int { return x - y } - fiberTemplate.Funcmap["markdown"] = func() func(s string) template.HTML { - mdParser := goldmark.New( - goldmark.WithExtensions(extension.GFM), - ) - return func(s string) template.HTML { - var sb strings.Builder - if err := mdParser.Convert([]byte(s), &sb); err != nil { + fiberTemplate.Funcmap["markdown"] = func() func(s string, site models.Site) template.HTML { + mdParser := markdown.NewRendererForUI() + return func(s string, site models.Site) template.HTML { + ctx := context.Background() + if site.ID != 0 { + ctx = models.WithSite(ctx, site) + } + + s, err := mdParser.Render(ctx, s) + if err != nil { return template.HTML("Markdown error: " + html.EscapeString(err.Error())) } - return template.HTML(sb.String()) + return template.HTML(s) } }() @@ -124,11 +126,13 @@ Starting weiro without any arguments will start the server. siteGroup.Delete("/posts/:postID", ph.Delete) siteGroup.Get("/uploads", uh.Index) + siteGroup.Get("/uploads/slug/+", uh.ShowFromSlug) siteGroup.Get("/uploads/:uploadID", uh.Show) siteGroup.Get("/uploads/:uploadID/raw", uh.ShowRaw) siteGroup.Post("/uploads/pending", uh.New) siteGroup.Post("/uploads/pending/:guid", uh.UploadPart) siteGroup.Post("/uploads/pending/:guid/finalize", uh.UploadComplete) + siteGroup.Delete("/uploads/:uploadID", uh.Delete) app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index) app.Get("/first-run", ih.FirstRun) diff --git a/go.mod b/go.mod index 35a2797..4178f45 100644 --- a/go.mod +++ b/go.mod @@ -19,14 +19,18 @@ require ( github.com/Azure/go-autorest/logger v0.1.0 // indirect github.com/Azure/go-autorest/tracing v0.5.0 // indirect github.com/Netflix/go-env v0.1.2 // indirect + github.com/PuerkitoBio/goquery v1.11.0 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/barasher/go-exiftool v1.10.0 // indirect github.com/cenkalti/backoff/v4 v4.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect + github.com/disintegration/imaging v1.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-openapi/analysis v0.19.16 // indirect github.com/go-openapi/errors v0.19.9 // indirect @@ -48,6 +52,7 @@ require ( github.com/gofiber/template/v2 v2.1.0 // indirect github.com/gofiber/utils/v2 v2.0.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.4 // indirect @@ -58,6 +63,7 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.33 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/mapstructure v1.4.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/netlify/open-api/v2 v2.49.1 // indirect @@ -77,6 +83,7 @@ require ( go.uber.org/multierr v1.6.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index faa9baf..0211568 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/Netflix/go-env v0.1.2 h1:0DRoLR9lECQ9Zqvkswuebm3jJ/2enaDX6Ei8/Z+EnK0= github.com/Netflix/go-env v0.1.2/go.mod h1:WlIhYi++8FlKNJtrop1mjXYAJMzv1f43K4MqCoh0yGE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= +github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= @@ -47,6 +49,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= @@ -57,6 +61,8 @@ github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:o github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGLmAjMPwCCCo7Jf0W6f9slllCkkv7vyc1yOSg= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs= github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -83,6 +89,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +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/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -257,6 +265,7 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -270,6 +279,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= @@ -357,6 +368,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -507,6 +520,10 @@ golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= @@ -520,6 +537,8 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -534,6 +553,10 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -561,8 +584,14 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= @@ -579,6 +608,11 @@ golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= @@ -612,15 +646,26 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -629,6 +674,11 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= @@ -664,6 +714,9 @@ golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200612220849-54c614fe050c/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= diff --git a/handlers/uploads.go b/handlers/uploads.go index 6b60313..fa2cb98 100644 --- a/handlers/uploads.go +++ b/handlers/uploads.go @@ -2,12 +2,14 @@ package handlers import ( "bufio" + "fmt" "io" "log" "net/http" "strconv" "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/services/uploads" "lmika.dev/pkg/modash/moslice" ) @@ -45,6 +47,23 @@ func (uh UploadsHandler) Show(c fiber.Ctx) error { return c.Render("uploads/show", fiber.Map{"upload": upload}) } +func (uh UploadsHandler) Delete(c fiber.Ctx) error { + uploadIDStr := c.Params("uploadID") + if uploadIDStr == "" { + return fiber.ErrBadRequest + } + uploadID, err := strconv.ParseInt(uploadIDStr, 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + if err := uh.UploadsService.DeleteUpload(c.Context(), uploadID); err != nil { + return err + } + + return c.Redirect().To(fmt.Sprintf("/sites/%v/uploads", models.MustGetSite(c.Context()).ID)) +} + func (uh UploadsHandler) ShowRaw(c fiber.Ctx) error { uploadIDStr := c.Params("uploadID") if uploadIDStr == "" { @@ -61,10 +80,29 @@ func (uh UploadsHandler) ShowRaw(c fiber.Ctx) error { return err } + return uh.serveUpload(c, upload, rwFn) +} + +func (uh UploadsHandler) ShowFromSlug(c fiber.Ctx) error { + uploadSlug := c.Params("+") + if uploadSlug == "" { + return fiber.ErrBadRequest + } + + upload, rwFn, err := uh.UploadsService.OpenUploadFromSlug(c.Context(), uploadSlug) + if err != nil { + log.Print(err) + return err + } + + return uh.serveUpload(c, upload, rwFn) +} + +func (uh UploadsHandler) serveUpload(c fiber.Ctx, upload models.Upload, uploadReader func() (io.ReadCloser, error)) error { c.Set("Content-Type", upload.MIMEType) c.Status(http.StatusOK) return c.SendStreamWriter(func(w *bufio.Writer) { - rw, err := rwFn() + rw, err := uploadReader() if err != nil { return } @@ -75,7 +113,6 @@ func (uh UploadsHandler) ShowRaw(c fiber.Ctx) error { return } }) - } func (uh UploadsHandler) New(c fiber.Ctx) error { diff --git a/models/ctx.go b/models/ctx.go index 60f93fe..5d3e808 100644 --- a/models/ctx.go +++ b/models/ctx.go @@ -25,3 +25,8 @@ func GetSite(ctx context.Context) (Site, bool) { site, ok := ctx.Value(siteKey).(Site) return site, ok } + +func MustGetSite(ctx context.Context) Site { + site, _ := GetSite(ctx) + return site +} diff --git a/models/uploads.go b/models/uploads.go index 110f639..60c95f4 100644 --- a/models/uploads.go +++ b/models/uploads.go @@ -20,6 +20,6 @@ type PendingUpload struct { UserID int64 `json:"user_id"` FileSize int64 `json:"file_size"` Filename string `json:"filename"` - MIMEType string `json:"mime_type"` + MIMEType string `json:"mime_mime"` UploadStarted time.Time `json:"upload_started"` } diff --git a/providers/markdown/htmltransforms.go b/providers/markdown/htmltransforms.go new file mode 100644 index 0000000..4697d45 --- /dev/null +++ b/providers/markdown/htmltransforms.go @@ -0,0 +1,27 @@ +package markdown + +import ( + "context" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +type htmlTransform func(ctx context.Context, dom *goquery.Document) error + +func applyTransforms(ctx context.Context, inHTML string, transforms []htmlTransform) string { + dom, err := goquery.NewDocumentFromReader(strings.NewReader(inHTML)) + if err != nil { + return inHTML + } + for _, transform := range transforms { + if err := transform(ctx, dom); err != nil { + return inHTML + } + } + res, err := dom.Html() + if err != nil { + return inHTML + } + return res +} diff --git a/providers/markdown/renderer.go b/providers/markdown/renderer.go new file mode 100644 index 0000000..aedd184 --- /dev/null +++ b/providers/markdown/renderer.go @@ -0,0 +1,86 @@ +package markdown + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/microcosm-cc/bluemonday" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + gm_html "github.com/yuin/goldmark/renderer/html" + "lmika.dev/lmika/weiro/models" +) + +type Renderer struct { + mdParser goldmark.Markdown + bmPolicy *bluemonday.Policy + htmlTransforms []htmlTransform +} + +func NewRendererForUI() *Renderer { + mdParser := goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithRendererOptions( + gm_html.WithUnsafe(), + ), + ) + bmPolicy := bluemonday.UGCPolicy() + + return &Renderer{ + mdParser: mdParser, + bmPolicy: bmPolicy, + htmlTransforms: []htmlTransform{ + rewriteImgSrc(func(ctx context.Context, src string) string { + if strings.HasPrefix(src, "/uploads/") { + if site, ok := models.GetSite(ctx); ok { + return fmt.Sprintf("/sites/%v/uploads/slug/%v", site.ID, strings.TrimPrefix(src, "/uploads/")) + } + return src + } + return src + }), + }, + } +} + +func NewRendererForSite() *Renderer { + mdParser := goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions( + gm_html.WithUnsafe(), + ), + ) + + return &Renderer{ + mdParser: mdParser, + } +} + +func (r *Renderer) Render(ctx context.Context, in string) (string, error) { + var sb strings.Builder + if err := r.mdParser.Convert([]byte(in), &sb); err != nil { + return "", err + } + + outHTML := applyTransforms(ctx, sb.String(), r.htmlTransforms) + + if r.bmPolicy != nil { + return r.bmPolicy.Sanitize(outHTML), nil + } + return sb.String(), nil +} + +func (r *Renderer) RenderTo(ctx context.Context, w io.Writer, in string) error { + s, err := r.Render(ctx, in) + if err != nil { + return err + } + _, err = w.Write([]byte(s)) + return err +} diff --git a/providers/markdown/uiexts.go b/providers/markdown/uiexts.go new file mode 100644 index 0000000..202fafd --- /dev/null +++ b/providers/markdown/uiexts.go @@ -0,0 +1,21 @@ +package markdown + +import ( + "context" + + "github.com/PuerkitoBio/goquery" +) + +func rewriteImgSrc(transform func(ctx context.Context, in string) string) htmlTransform { + return func(ctx context.Context, dom *goquery.Document) error { + dom.Find("img").Each(func(i int, s *goquery.Selection) { + s.SetAttr("src", transform(ctx, s.AttrOr("src", ""))) + }) + dom.Find("img").Each(func(i int, s *goquery.Selection) { + s.AddClass("img-fluid") + }) + //SetAttr("style", "max-width: 200px;max-height: 200px;height: auto;") + //log.Printf("Rewritten image src: %s", s.AttrOr("style", "")) + return nil + } +} diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 90e8610..acbb62b 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -2,6 +2,7 @@ package sitebuilder import ( "bytes" + "context" "fmt" "html/template" "io" @@ -10,18 +11,15 @@ import ( "sort" "strings" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/extension" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer/html" "golang.org/x/sync/errgroup" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/models/pubmodel" + "lmika.dev/lmika/weiro/providers/markdown" ) type Builder struct { site pubmodel.Site - gmMarkdown goldmark.Markdown + mdRenderer *markdown.Renderer opts Options tmpls *template.Template } @@ -35,18 +33,10 @@ func New(site pubmodel.Site, opts Options) (*Builder, error) { } return &Builder{ - site: site, - opts: opts, - tmpls: tmpls, - gmMarkdown: goldmark.New( - goldmark.WithExtensions(extension.GFM), - goldmark.WithParserOptions( - parser.WithAutoHeadingID(), - ), - goldmark.WithRendererOptions( - html.WithUnsafe(), - ), - ), + site: site, + opts: opts, + tmpls: tmpls, + mdRenderer: markdown.NewRendererForSite(), }, nil } @@ -121,7 +111,7 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) { } var md bytes.Buffer - if err := b.gmMarkdown.Convert([]byte(post.Body), &md); err != nil { + if err := b.mdRenderer.RenderTo(context.Background(), &md, post.Body); err != nil { return postSingleData{}, fmt.Errorf("failed to write post %s: %w", post.Slug, err) } diff --git a/providers/uploadfiles/exif.go b/providers/uploadfiles/exif.go index a6f2ae7..37702da 100644 --- a/providers/uploadfiles/exif.go +++ b/providers/uploadfiles/exif.go @@ -1,31 +1,57 @@ package uploadfiles import ( + "os" + "path/filepath" + "strings" + "emperror.dev/errors" - "github.com/barasher/go-exiftool" + "github.com/disintegration/imaging" "lmika.dev/lmika/weiro/models" ) +const ( + applicationOctetStream = "application/octet-stream" +) + +var supportedRencodableImageTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + applicationOctetStream: true, +} +var supportedReencoableExtensions = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, +} + func (p *Provider) StripeEXIFData(site models.Site, up models.Upload) error { uploadFilename := p.uploadFileName(site, up) - et, err := exiftool.NewExiftool() + if !supportedRencodableImageTypes[up.MIMEType] { + return errors.New("unsupported image format: " + up.MIMEType) + } + if up.MIMEType == applicationOctetStream && !supportedReencoableExtensions[filepath.Ext(uploadFilename)] { + return errors.New("unsupported image format") + } + + img, err := imaging.Open(uploadFilename) if err != nil { - return err + return errors.Wrap(err, "failed to open image file") } - defer et.Close() - fileInfos := et.ExtractMetadata(uploadFilename) - if len(fileInfos) == 0 { - return errors.New("no exif data found") + tmpName := strings.TrimSuffix(uploadFilename, filepath.Ext(uploadFilename)) + ".tmp." + filepath.Ext(uploadFilename) + if err := imaging.Save(img, tmpName); err != nil { + return errors.Wrap(err, "failed to save image file") } - fileInfo := fileInfos[0] - fileInfo.ClearAll() - fileOut := []exiftool.FileMetadata{fileInfo} - et.WriteMetadata(fileOut) - if fileOut[0].Err != nil { - return fileOut[0].Err + if err := os.Remove(uploadFilename); err != nil { + _ = os.Remove(tmpName) + return errors.Wrap(err, "failed to remove image file") + } + + if err := os.Rename(tmpName, uploadFilename); err != nil { + return errors.Wrap(err, "failed to rename image file") } return nil diff --git a/providers/uploadfiles/provider.go b/providers/uploadfiles/provider.go index b0ed4c1..eb7d5de 100644 --- a/providers/uploadfiles/provider.go +++ b/providers/uploadfiles/provider.go @@ -37,6 +37,17 @@ func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser return os.Open(fullPath) } +func (p *Provider) DeleteUpload(site models.Site, up models.Upload) error { + fullPath := p.uploadFileName(site, up) + if err := os.Remove(fullPath); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return nil +} + func (p *Provider) UploadDir(site models.Site) string { return filepath.Join(p.baseDir, site.GUID) } diff --git a/services/uploads/manage.go b/services/uploads/manage.go index 7176910..32debac 100644 --- a/services/uploads/manage.go +++ b/services/uploads/manage.go @@ -39,6 +39,24 @@ func (s *Service) FetchUpload(ctx context.Context, uploadID int64) (res UploadWi }, nil } +func (s *Service) DeleteUpload(ctx context.Context, uploadID int64) error { + site, _, err := s.fetchSiteAndUser(ctx) + if err != nil { + return err + } + + upload, err := s.db.SelectUploadByID(ctx, uploadID) + if err != nil { + return err + } + + if err := s.up.DeleteUpload(site, upload); err != nil { + return err + } + + return s.db.DeleteUpload(ctx, uploadID) +} + func (s *Service) renderCopyTemplate(upload models.Upload) string { var sb strings.Builder @@ -93,3 +111,25 @@ func (s *Service) OpenUpload(ctx context.Context, id int64) (models.Upload, func return rw, nil }, nil } + +func (s *Service) OpenUploadFromSlug(ctx context.Context, slug string) (models.Upload, func() (io.ReadCloser, error), error) { + site, _, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.Upload{}, nil, err + } + + upload, err := s.db.SelectUploadBySiteIDAndSlug(ctx, site.ID, slug) + if err != nil { + return models.Upload{}, nil, err + } else if upload.SiteID != site.ID { + return models.Upload{}, nil, models.NotFoundError + } + + return upload, func() (io.ReadCloser, error) { + rw, err := s.up.OpenUpload(site, upload) + if err != nil { + return nil, err + } + return rw, nil + }, nil +} diff --git a/services/uploads/pending.go b/services/uploads/pending.go index 0e281c3..eaa5efb 100644 --- a/services/uploads/pending.go +++ b/services/uploads/pending.go @@ -19,7 +19,7 @@ import ( type NewPendingRequest struct { FileSize int64 `json:"size"` Filename string `json:"name"` - MIMEType string `json:"type"` + MIMEType string `json:"mime"` } func (s *Service) NewPending(ctx context.Context, req NewPendingRequest) (models.PendingUpload, error) { @@ -126,7 +126,7 @@ func (s *Service) FinalizePending(ctx context.Context, pendingGUID string, expec if err := s.up.AdoptFile(site, newUpload, pendingDataFilename); err != nil { return err } - if err := s.db.DeletePendingUpload(ctx, newUpload.GUID); err != nil { + if err := s.db.DeletePendingUpload(ctx, pendingGUID); err != nil { return err } if err := s.up.StripeEXIFData(site, newUpload); err != nil { diff --git a/views/_common/nav.html b/views/_common/nav.html index 8729c09..7b8bd16 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -10,6 +10,9 @@ +