Have got uploads working

This commit is contained in:
Leon Mika 2026-03-02 20:48:41 +11:00
parent 97112d99dd
commit 6b697e008f
20 changed files with 751 additions and 7 deletions

View file

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

View file

@ -5,10 +5,12 @@ import PostlistController from "./controllers/postlist";
import PosteditController from "./controllers/postedit"; import PosteditController from "./controllers/postedit";
import LogoutController from "./controllers/logout"; import LogoutController from "./controllers/logout";
import FirstRunController from "./controllers/firstrun"; import FirstRunController from "./controllers/firstrun";
import UploadController from "./controllers/upload";
window.Stimulus = Application.start() window.Stimulus = Application.start()
Stimulus.register("toast", ToastController); Stimulus.register("toast", ToastController);
Stimulus.register("postlist", PostlistController); Stimulus.register("postlist", PostlistController);
Stimulus.register("postedit", PosteditController); Stimulus.register("postedit", PosteditController);
Stimulus.register("logout", LogoutController); Stimulus.register("logout", LogoutController);
Stimulus.register("first-run", FirstRunController); Stimulus.register("first-run", FirstRunController);
Stimulus.register("upload", UploadController);

View file

@ -108,6 +108,7 @@ Starting weiro without any arguments will start the server.
ih := handlers.IndexHandler{SiteService: svcs.Sites} ih := handlers.IndexHandler{SiteService: svcs.Sites}
lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth} lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth}
ph := handlers.PostsHandler{PostService: svcs.Posts} ph := handlers.PostsHandler{PostService: svcs.Posts}
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
app.Get("/login", lh.Login) app.Get("/login", lh.Login)
app.Post("/login", lh.DoLogin) 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.Patch("/posts/:postID", ph.Patch)
siteGroup.Delete("/posts/:postID", ph.Delete) 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("/", middleware.OptionalUser(svcs.Auth), ih.Index)
app.Get("/first-run", ih.FirstRun) app.Get("/first-run", ih.FirstRun)
app.Post("/first-run", ih.FirstRunSubmit) app.Post("/first-run", ih.FirstRunSubmit)

View file

@ -9,6 +9,7 @@ import (
type Config struct { type Config struct {
DataDir string `env:"DATA_DIR"` DataDir string `env:"DATA_DIR"`
ScratchDir string `env:"SCRATCH_DIR"`
SiteDomain string `env:"SITE_DOMAIN"` SiteDomain string `env:"SITE_DOMAIN"`
LoginLocked bool `env:"LOGIN_LOCKED,default=false"` LoginLocked bool `env:"LOGIN_LOCKED,default=false"`
Env string `env:"ENV,default=prod"` Env string `env:"ENV,default=prod"`

View file

@ -2,7 +2,6 @@ package handlers
import ( import (
"fmt" "fmt"
"log"
"strconv" "strconv"
"github.com/gofiber/fiber/v3" "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 { func (ph PostsHandler) Patch(c fiber.Ctx) error {
log.Println("PATCH")
postIDStr := c.Params("postID") postIDStr := c.Params("postID")
if postIDStr == "" { if postIDStr == "" {
return fiber.ErrBadRequest return fiber.ErrBadRequest
@ -110,8 +107,6 @@ func (ph PostsHandler) Patch(c fiber.Ctx) error {
return err return err
} }
log.Println("Request")
switch req.Action { switch req.Action {
case "restore": case "restore":
if err := ph.PostService.RestorePost(c.Context(), postID); err != nil { if err := ph.PostService.RestorePost(c.Context(), postID); err != nil {

62
handlers/uploads.go Normal file
View file

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

View file

@ -6,7 +6,7 @@ type userKeyType struct{}
type siteKeyType struct{} type siteKeyType struct{}
var userKey = userKeyType{} var userKey = userKeyType{}
var siteKey = userKeyType{} var siteKey = siteKeyType{}
func WithUser(ctx context.Context, user User) context.Context { func WithUser(ctx context.Context, user User) context.Context {
return context.WithValue(ctx, userKey, user) return context.WithValue(ctx, userKey, user)

23
models/uploads.go Normal file
View file

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

View file

@ -4,6 +4,17 @@
package sqlgen package sqlgen
type PendingUpload struct {
ID interface{}
SiteID int64
Guid string
UserID int64
Filename string
FileSize int64
MimeType string
UploadStartedAt int64
}
type Post struct { type Post struct {
ID int64 ID int64
SiteID int64 SiteID int64
@ -38,6 +49,16 @@ type Site struct {
CreatedAt int64 CreatedAt int64
} }
type Upload struct {
ID interface{}
SiteID int64
Guid string
MimeType string
Filename string
Alt string
CreatedAt int64
}
type User struct { type User struct {
ID int64 ID int64
Username string Username string

View file

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

View file

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

107
providers/db/uploads.go Normal file
View file

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

View file

@ -57,6 +57,13 @@ func (s *Service) fetchPostAndSite(ctx context.Context, pid int64) (*models.Post
return nil, models.Site{}, models.SiteRequiredError 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) post, err := s.db.SelectPost(ctx, pid)
if err != nil { if err != nil {
return nil, models.Site{}, err return nil, models.Site{}, err

View file

@ -9,6 +9,7 @@ import (
"lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/posts"
"lmika.dev/lmika/weiro/services/publisher" "lmika.dev/lmika/weiro/services/publisher"
"lmika.dev/lmika/weiro/services/sites" "lmika.dev/lmika/weiro/services/sites"
"lmika.dev/lmika/weiro/services/uploads"
) )
type Services struct { type Services struct {
@ -18,6 +19,7 @@ type Services struct {
PublisherQueue *publisher.Queue PublisherQueue *publisher.Queue
Posts *posts.Service Posts *posts.Service
Sites *sites.Service Sites *sites.Service
Uploads *uploads.Service
} }
func New(cfg config.Config) (*Services, error) { func New(cfg config.Config) (*Services, error) {
@ -31,6 +33,7 @@ func New(cfg config.Config) (*Services, error) {
publisherQueue := publisher.NewQueue(publisherSvc) publisherQueue := publisher.NewQueue(publisherSvc)
postService := posts.New(dbp, publisherQueue) postService := posts.New(dbp, publisherQueue)
siteService := sites.New(dbp) siteService := sites.New(dbp)
uploadService := uploads.New(dbp, filepath.Join(cfg.ScratchDir, "uploads", "pending"))
return &Services{ return &Services{
DB: dbp, DB: dbp,
@ -39,6 +42,7 @@ func New(cfg config.Config) (*Services, error) {
PublisherQueue: publisherQueue, PublisherQueue: publisherQueue,
Posts: postService, Posts: postService,
Sites: siteService, Sites: siteService,
Uploads: uploadService,
}, nil }, nil
} }

124
services/uploads/pending.go Normal file
View file

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

View file

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

View file

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

22
sql/queries/uploads.sql Normal file
View file

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

View file

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

6
views/uploads/index.html Normal file
View file

@ -0,0 +1,6 @@
<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 btn-success" data-action="upload#upload">Upload</button>
</div>
</main>