Uploads #1

Merged
lmika merged 6 commits from feature/uploads into main 2026-03-05 10:03:47 +00:00
18 changed files with 327 additions and 45 deletions
Showing only changes of commit 48f39133d7 - Show all commits

View file

@ -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) {

View file

@ -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)

2
go.mod
View file

@ -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

6
go.sum
View file

@ -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=

View file

@ -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 {

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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(),
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -1,20 +1,21 @@
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,
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,
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INT NOT NULL,
guid TEXT NOT NULL,
user_id INT NOT NULL,

View file

@ -3,4 +3,18 @@
data-controller="upload" data-upload-site-id-value="{{ .site.ID }}">
<button class="btn btn-success" data-action="upload#upload">Upload</button>
</div>
{{ range .uploads }}
<div class="row row-cols-5">
{{ range . }}
<div class="col">
<a href="/sites/{{ $.site.ID }}/uploads/{{ .Upload.ID }}">
<div class="ratio ratio-1x1 m-2">
<img src="{{ .URL }}" alt="{{ .Upload.Alt }}" class="img-fluid object-fit-contain">
</div>
</a>
</div>
{{ end }}
</div>
{{ end }}
</main>

8
views/uploads/show.html Normal file
View file

@ -0,0 +1,8 @@
<main class="container">
<div class="my-4 d-flex justify-content-between align-items-baseline"
data-controller="upload" data-upload-site-id-value="{{ .site.ID }}">
<button class="btn" data-action="upload#upload">Copy HTML</button>
</div>
<img src="{{ .URL }}" alt="{{ .Upload.Alt }}" class="img-fluid">
</main>