Petal is now an OIDC client in its own right rather than trusting a header from the proxy. The Phase-0 Resolver seam was the only integration point: main.go picks the session store when Authentik is configured and the static local user otherwise, and no handler or query moved for either. internal/auth gains three pieces. session.go issues an opaque cookie token and stores only its SHA-256, so a database copy yields nothing usable; the 30-day expiry slides on every request, throttled to one write an hour, and logout deletes the row rather than just the cookie. oidc.go runs the authorization-code flow with state, nonce and PKCE, and discovers the provider lazily and on retry — an Authentik outage should block new logins without stopping Petal booting or invalidating live sessions. users.go provisions accounts from the token's claims and gates them on an allowlist that matches emails as well as subject ids, since a subject is an opaque uuid that doesn't exist until someone has already logged in once. Migration 0010 lands sessions, images and users.pair_lang together. The images table closes the capability-URL hole the Phase-0 audit flagged: a hash was previously enough to fetch anyone's picture. Rows are keyed (name, user_id) so one file can have several owners and deduplication survives; a stranger gets 404 rather than 403, the cache header drops to private, and files already on disk are claimed at startup or every image already pasted into a document would 404. On the frontend a single 401 interceptor feeds a warm bilingual sign-in overlay, drawn over a still-visible editor because nothing has been taken away. Behind it is the part that matters: a save that comes back 401 stashes its body to localStorage before anything else and stops the auto-save loop, and reopening that document after signing in merges the draft back and saves it. An expired session must not cost writing. Writing the round-trip test against a stub identity provider turned up a real bug: the one-shot state/nonce/PKCE cookies were cleared in a defer, which runs after the redirect has written the response header, so the clearing Set-Cookie was silently dropped and they lingered for their full ten minutes. Also swaps the emoji favicon for a drawn sakura, which renders as Petal's own rose palette everywhere instead of whatever each platform's font decides, and doubles as the app tile in Authentik. Migration 0010 verified against a VACUUM INTO copy of the live millenia database: counts intact, FTS still matching, the one existing image claimed. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
236 lines
7.3 KiB
Go
236 lines
7.3 KiB
Go
package images
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/auth"
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
)
|
|
|
|
// a 1x1 transparent PNG.
|
|
var pngBytes = []byte{
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
|
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
|
|
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
|
0x42, 0x60, 0x82,
|
|
}
|
|
|
|
// another 1x1 PNG, differing in one pixel byte, so it hashes elsewhere.
|
|
var otherPNG = append(append([]byte{}, pngBytes[:len(pngBytes)-8]...),
|
|
0x01, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44)
|
|
|
|
// newStore returns a handler over a fresh directory and database, plus a router
|
|
// per user: identical but for who the auth middleware says is calling. Two users
|
|
// over one store is the situation that ownership exists to handle.
|
|
func newStore(t *testing.T) (dir string, alice, bob http.Handler) {
|
|
t.Helper()
|
|
dir = t.TempDir()
|
|
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
t.Cleanup(func() { database.Close() })
|
|
|
|
if _, err := database.Exec(
|
|
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`,
|
|
"bob", "bob@petal.local", "Bob",
|
|
); err != nil {
|
|
t.Fatalf("seed second user: %v", err)
|
|
}
|
|
|
|
h, err := New(dir, database.DB, db.LocalUserID)
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
mount := func(userID string) http.Handler {
|
|
return auth.Middleware(auth.StaticResolver(userID))(h.Routes())
|
|
}
|
|
return dir, mount(db.LocalUserID), mount("bob")
|
|
}
|
|
|
|
func uploadReq(t *testing.T, field string, data []byte) *http.Request {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
fw, err := mw.CreateFormFile(field, "x.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fw.Write(data)
|
|
mw.Close()
|
|
req := httptest.NewRequest(http.MethodPost, "/", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
return req
|
|
}
|
|
|
|
// upload posts an image and returns its stored name.
|
|
func upload(t *testing.T, h http.Handler, data []byte) string {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, uploadReq(t, "image", data))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("upload code=%d body=%s", rec.Code, rec.Body)
|
|
}
|
|
var resp struct{ URL string }
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(resp.URL, "/api/images/") || !strings.HasSuffix(resp.URL, ".png") {
|
|
t.Fatalf("unexpected url %q", resp.URL)
|
|
}
|
|
return strings.TrimPrefix(resp.URL, "/api/images/")
|
|
}
|
|
|
|
func get(t *testing.T, h http.Handler, name string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/"+name, nil))
|
|
return rec
|
|
}
|
|
|
|
func TestUploadAndServe(t *testing.T) {
|
|
_, alice, _ := newStore(t)
|
|
|
|
name := upload(t, alice, pngBytes)
|
|
|
|
// The same content uploaded again dedupes to the same URL.
|
|
if again := upload(t, alice, pngBytes); again != name {
|
|
t.Fatalf("expected dedup to same name, got %q vs %q", again, name)
|
|
}
|
|
|
|
rec := get(t, alice, name)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("serve code=%d", rec.Code)
|
|
}
|
|
if !bytes.Equal(rec.Body.Bytes(), pngBytes) {
|
|
t.Fatal("served bytes differ from uploaded")
|
|
}
|
|
}
|
|
|
|
// The point of the ownership table: a hash is not a capability.
|
|
func TestImageIsolation(t *testing.T) {
|
|
_, alice, bob := newStore(t)
|
|
name := upload(t, alice, pngBytes)
|
|
|
|
if rec := get(t, bob, name); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("bob fetched alice's image: code=%d", rec.Code)
|
|
}
|
|
|
|
// Nor can he delete it out from under her.
|
|
rec := httptest.NewRecorder()
|
|
bob.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/"+name, nil))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("bob deleted alice's image: code=%d", rec.Code)
|
|
}
|
|
if got := get(t, alice, name); got.Code != http.StatusOK {
|
|
t.Fatalf("alice's image disappeared: code=%d", got.Code)
|
|
}
|
|
}
|
|
|
|
// Deduplication has to survive ownership: one file, one row each.
|
|
func TestDedupAcrossUsers(t *testing.T) {
|
|
dir, alice, bob := newStore(t)
|
|
|
|
name := upload(t, alice, pngBytes)
|
|
if bobName := upload(t, bob, pngBytes); bobName != name {
|
|
t.Fatalf("expected the same stored name, got %q vs %q", bobName, name)
|
|
}
|
|
|
|
entries, _ := os.ReadDir(dir)
|
|
if len(entries) != 1 {
|
|
t.Fatalf("expected 1 file on disk, found %d", len(entries))
|
|
}
|
|
for _, h := range []http.Handler{alice, bob} {
|
|
if rec := get(t, h, name); rec.Code != http.StatusOK {
|
|
t.Fatalf("owner could not fetch shared image: code=%d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// Alice dropping her copy must not take Bob's picture away with it.
|
|
rec := httptest.NewRecorder()
|
|
alice.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/"+name, nil))
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("delete code=%d", rec.Code)
|
|
}
|
|
if got := get(t, alice, name); got.Code != http.StatusNotFound {
|
|
t.Fatalf("alice still sees a deleted image: code=%d", got.Code)
|
|
}
|
|
if got := get(t, bob, name); got.Code != http.StatusOK {
|
|
t.Fatalf("bob lost his image when alice deleted hers: code=%d", got.Code)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
|
|
t.Fatalf("file removed while still owned: %v", err)
|
|
}
|
|
|
|
// The last owner leaving takes the file with them.
|
|
rec2 := httptest.NewRecorder()
|
|
bob.ServeHTTP(rec2, httptest.NewRequest(http.MethodDelete, "/"+name, nil))
|
|
if rec2.Code != http.StatusNoContent {
|
|
t.Fatalf("delete code=%d", rec2.Code)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) {
|
|
t.Fatalf("file survived its last owner: %v", err)
|
|
}
|
|
}
|
|
|
|
// Images that predate ownership must not vanish from documents that use them.
|
|
func TestBackfillClaimsExistingFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer database.Close()
|
|
|
|
orphan := "deadbeefdeadbeefdeadbeefdeadbeef.png"
|
|
if err := os.WriteFile(filepath.Join(dir, orphan), pngBytes, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
h, err := New(dir, database.DB, db.LocalUserID)
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
alice := auth.Middleware(auth.StaticResolver(db.LocalUserID))(h.Routes())
|
|
if rec := get(t, alice, orphan); rec.Code != http.StatusOK {
|
|
t.Fatalf("pre-existing image not claimed: code=%d", rec.Code)
|
|
}
|
|
|
|
// Re-running the backfill (i.e. a restart) must not double up or reassign.
|
|
if _, err := New(dir, database.DB, "bob"); err != nil {
|
|
t.Fatalf("second backfill: %v", err)
|
|
}
|
|
var owners int
|
|
if err := database.QueryRow(`SELECT COUNT(*) FROM images WHERE name = ?`, orphan).Scan(&owners); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if owners != 1 {
|
|
t.Fatalf("expected the backfill to be idempotent, got %d owners", owners)
|
|
}
|
|
}
|
|
|
|
func TestUploadRejectsNonImage(t *testing.T) {
|
|
_, alice, _ := newStore(t)
|
|
rec := httptest.NewRecorder()
|
|
alice.ServeHTTP(rec, uploadReq(t, "image", []byte("this is plainly not an image at all")))
|
|
if rec.Code != http.StatusUnsupportedMediaType {
|
|
t.Fatalf("expected 415, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeMissing(t *testing.T) {
|
|
_, alice, _ := newStore(t)
|
|
if rec := get(t, alice, "deadbeef.png"); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", rec.Code)
|
|
}
|
|
}
|