t5de-catalog: store and serve community product files
CI / build (push) Successful in 54s

A Go server that takes product uploads with hub upload tokens, checks them by
t5de-api's pack rules, stores files by sha256, registers revisions with the hub
(POST /api/v1/catalog/revisions), and serves files with immutable caching;
files used only by adult revisions need an adult access token. Contract:
t5de-hub's docs/products.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 18:59:16 -07:00
commit 8742829e77
20 files changed
+2348

No files matched your search

+217
View File
@@ -0,0 +1,217 @@
// Package store keeps the catalog's files and the revisions it hosts: files on disk named by their sha256
// (files/<first 2 hex>/<sha256>), revisions in SQLite (catalog.db).
package store
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
_ "modernc.org/sqlite"
)
// Ratings.
const (
General = "general"
Adult = "adult"
)
// File is one entry of a revision's file list.
type File struct {
Name string `json:"name"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
// Revision is a revision the catalog hosts.
type Revision struct {
Product string `json:"product"`
Revision int `json:"revision"`
Rating string `json:"rating"`
Files []File `json:"files"` // sorted by name
}
// ErrNotFound is returned for files and revisions the catalog doesn't host.
var ErrNotFound = errors.New("not found")
var hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
// ValidHash reports whether s is a lowercase hex sha256.
func ValidHash(s string) bool { return hashPattern.MatchString(s) }
// Store is the catalog's data folder.
type Store struct {
dir string
db *sql.DB
}
const schema = `
CREATE TABLE IF NOT EXISTS revisions (
product TEXT NOT NULL,
revision INTEGER NOT NULL,
rating TEXT NOT NULL CHECK (rating IN ('general', 'adult')),
files TEXT NOT NULL, -- the file list, JSON
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (product, revision)
);
CREATE TABLE IF NOT EXISTS revision_files (
product TEXT NOT NULL,
revision INTEGER NOT NULL,
sha256 TEXT NOT NULL,
PRIMARY KEY (product, revision, sha256),
FOREIGN KEY (product, revision) REFERENCES revisions (product, revision) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS revision_files_sha256 ON revision_files (sha256);
`
// Open opens (or creates) the store in dir.
func Open(dir string) (*Store, error) {
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
return nil, err
}
// Leftovers of uploads interrupted by a crash.
if err := os.RemoveAll(filepath.Join(dir, "tmp")); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(dir, "tmp"), 0o755); err != nil {
return nil, err
}
dsn := "file:" + filepath.ToSlash(filepath.Join(dir, "catalog.db")) +
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("catalog.db: %w", err)
}
return &Store{dir: dir, db: db}, nil
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// TempDir is a folder on the store's file system for temporary files (emptied when the store opens).
func (s *Store) TempDir() string { return filepath.Join(s.dir, "tmp") }
// Path returns where the file with this hash is (or would be) stored.
func (s *Store) Path(hash string) string {
return filepath.Join(s.dir, "files", hash[:2], hash)
}
// PutFile stores data under its sha256 and returns the hash. A file already stored isn't written again.
func (s *Store) PutFile(data []byte) (string, error) {
sum := sha256.Sum256(data)
hash := hex.EncodeToString(sum[:])
path := s.Path(hash)
if fi, err := os.Stat(path); err == nil && fi.Size() == int64(len(data)) {
return hash, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", err
}
f, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
if err != nil {
return "", err
}
tmp := f.Name()
_, err = f.Write(data)
if err == nil {
err = f.Sync()
}
if cerr := f.Close(); err == nil {
err = cerr
}
if err == nil {
err = os.Rename(tmp, path)
}
if err != nil {
os.Remove(tmp)
return "", err
}
return hash, nil
}
// Open opens a stored file.
func (s *Store) Open(hash string) (*os.File, error) {
if !ValidHash(hash) {
return nil, ErrNotFound
}
f, err := os.Open(s.Path(hash))
if errors.Is(err, fs.ErrNotExist) {
return nil, ErrNotFound
}
return f, err
}
// AddRevision records a revision as hosted. Its files must be stored already. Recording the same revision again
// replaces it.
func (s *Store) AddRevision(r Revision) error {
if r.Rating != General && r.Rating != Adult {
return fmt.Errorf("rating %q", r.Rating)
}
files, err := json.Marshal(r.Files)
if err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM revisions WHERE product = ? AND revision = ?`, r.Product, r.Revision); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO revisions (product, revision, rating, files) VALUES (?, ?, ?, ?)`,
r.Product, r.Revision, r.Rating, string(files)); err != nil {
return err
}
for _, f := range r.Files {
if _, err := tx.Exec(`INSERT OR IGNORE INTO revision_files (product, revision, sha256) VALUES (?, ?, ?)`,
r.Product, r.Revision, f.SHA256); err != nil {
return err
}
}
return tx.Commit()
}
// Revision returns a hosted revision.
func (s *Store) Revision(product string, revision int) (*Revision, error) {
r := Revision{Product: product, Revision: revision}
var files string
err := s.db.QueryRow(`SELECT rating, files FROM revisions WHERE product = ? AND revision = ?`, product, revision).
Scan(&r.Rating, &files)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(files), &r.Files); err != nil {
return nil, fmt.Errorf("revision %s/%d: %w", product, revision, err)
}
return &r, nil
}
// FileAccess tells whether a file belongs to a hosted revision (hosted) and whether every such revision is adult
// (adultOnly). Files in no hosted revision (an upload the hub refused) aren't served.
func (s *Store) FileAccess(hash string) (hosted, adultOnly bool, err error) {
var n, general int
err = s.db.QueryRow(`
SELECT count(*), coalesce(sum(r.rating = 'general'), 0)
FROM revision_files rf JOIN revisions r ON r.product = rf.product AND r.revision = rf.revision
WHERE rf.sha256 = ?`, hash).Scan(&n, &general)
if err != nil {
return false, false, err
}
return n > 0, n > 0 && general == 0, nil
}
+147
View File
@@ -0,0 +1,147 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"testing"
)
func open(t *testing.T, dir string) *Store {
t.Helper()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func hashOf(data string) string {
sum := sha256.Sum256([]byte(data))
return hex.EncodeToString(sum[:])
}
func TestFiles(t *testing.T) {
dir := t.TempDir()
s := open(t, dir)
hash, err := s.PutFile([]byte("hello"))
if err != nil {
t.Fatal(err)
}
if hash != hashOf("hello") {
t.Fatalf("hash %s", hash)
}
want := filepath.Join(dir, "files", hash[:2], hash)
if s.Path(hash) != want {
t.Fatalf("path %s, want %s", s.Path(hash), want)
}
again, err := s.PutFile([]byte("hello"))
if err != nil || again != hash {
t.Fatalf("again: %s, %v", again, err)
}
f, err := s.Open(hash)
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(f)
f.Close()
if string(data) != "hello" {
t.Fatalf("data %q", data)
}
// No temp files left beside it.
entries, _ := os.ReadDir(filepath.Dir(want))
if len(entries) != 1 {
t.Fatalf("%d entries in %s", len(entries), filepath.Dir(want))
}
if _, err := s.Open(hashOf("missing")); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing: %v", err)
}
for _, bad := range []string{"", "../catalog.db", hash[:63], "A" + hash[1:]} {
if _, err := s.Open(bad); !errors.Is(err, ErrNotFound) {
t.Fatalf("%q: %v", bad, err)
}
}
}
func TestRevisions(t *testing.T) {
dir := t.TempDir()
s := open(t, dir)
shared, general, adult := hashOf("shared"), hashOf("general"), hashOf("adult")
if hosted, _, err := s.FileAccess(shared); err != nil || hosted {
t.Fatalf("before: hosted %v, %v", hosted, err)
}
a := Revision{Product: "aaaaaaaaaa", Revision: 1, Rating: Adult, Files: []File{
{Name: "a.png", SHA256: adult, Size: 5}, {Name: "index.xml", SHA256: shared, Size: 6},
}}
if err := s.AddRevision(a); err != nil {
t.Fatal(err)
}
check := func(hash string, wantHosted, wantAdult bool) {
t.Helper()
hosted, adultOnly, err := s.FileAccess(hash)
if err != nil || hosted != wantHosted || adultOnly != wantAdult {
t.Fatalf("%s: hosted %v adult %v (%v), want %v %v", hash[:8], hosted, adultOnly, err, wantHosted, wantAdult)
}
}
check(shared, true, true)
check(adult, true, true)
check(general, false, false)
g := Revision{Product: "bbbbbbbbbb", Revision: 2, Rating: General, Files: []File{
{Name: "g.png", SHA256: general, Size: 7}, {Name: "index.xml", SHA256: shared, Size: 6},
}}
if err := s.AddRevision(g); err != nil {
t.Fatal(err)
}
check(shared, true, false) // also in a general revision
check(adult, true, true)
check(general, true, false)
got, err := s.Revision("aaaaaaaaaa", 1)
if err != nil || !reflect.DeepEqual(*got, a) {
t.Fatalf("got %+v, %v", got, err)
}
if _, err := s.Revision("aaaaaaaaaa", 2); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing revision: %v", err)
}
if err := s.AddRevision(Revision{Product: "c", Revision: 1, Rating: "GA"}); err == nil {
t.Fatal("bad rating accepted")
}
// Recording a revision again replaces it, and the data survives reopening.
a.Rating = General
if err := s.AddRevision(a); err != nil {
t.Fatal(err)
}
s.Close()
s = open(t, dir)
got, err = s.Revision("aaaaaaaaaa", 1)
if err != nil || got.Rating != General {
t.Fatalf("after reopening: %+v, %v", got, err)
}
if hosted, adultOnly, _ := s.FileAccess(adult); !hosted || adultOnly {
t.Fatal("adult file still adult-only after the revision became general")
}
}
func TestTempCleared(t *testing.T) {
dir := t.TempDir()
s := open(t, dir)
if err := os.WriteFile(filepath.Join(s.TempDir(), "leftover"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
s.Close()
s = open(t, dir)
if entries, _ := os.ReadDir(s.TempDir()); len(entries) != 0 {
t.Fatalf("temp folder not emptied: %v", entries)
}
}