Files
dhkatzandClaude Opus 5.5 8742829e77
CI / build (push) Successful in 54s
t5de-catalog: store and serve community product files
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]>
2026-10-29 18:59:16 -07:00

114 lines
3.1 KiB
Go

// Package hub is the catalog's client for T5DE's hub (server to server, docs/products.md "For catalogs").
package hub
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"git.t5de.dev/t5de/t5de-catalog/internal/store"
)
// Client calls the hub as one catalog.
type Client struct {
url string
auth string
client *http.Client
}
// New returns a client for the hub at hubURL (its API), as catalog id with its key. client may be nil.
func New(hubURL, id, key string, client *http.Client) *Client {
if client == nil {
client = &http.Client{Timeout: 60 * time.Second}
}
return &Client{url: strings.TrimRight(hubURL, "/"), auth: "Catalog " + id + ":" + key, client: client}
}
// NewRevision is what the catalog registers.
type NewRevision struct {
UploadToken string `json:"uploadToken"`
Files []store.File `json:"files"`
Rating string `json:"rating"`
Parent int64 `json:"parent"`
Name string `json:"name"`
Notes string `json:"notes"`
}
// Registered is the hub's answer.
type Registered struct {
Product string `json:"product"`
Revision int `json:"revision"`
}
// Error is the hub refusing a request: its status and, when it sent them, its error code and message.
type Error struct {
Status int
Code string
Message string
}
func (e *Error) Error() string {
if e.Code != "" {
return fmt.Sprintf("hub: %d %s: %s", e.Status, e.Code, e.Message)
}
return fmt.Sprintf("hub: %d", e.Status)
}
var productID = regexp.MustCompile(`^[a-z2-7]{10}$`)
// ValidProduct reports whether s is a product ID (10 of a-z2-7).
func ValidProduct(s string) bool { return productID.MatchString(s) }
// RegisterRevision posts a revision to the hub (POST /api/v1/catalog/revisions). A refusal is an *Error; other
// errors mean the hub couldn't be reached or answered nonsense.
func (c *Client) RegisterRevision(ctx context.Context, r NewRevision) (*Registered, error) {
if r.Files == nil {
r.Files = []store.File{}
}
body, err := json.Marshal(r)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url+"/api/v1/catalog/revisions", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", c.auth)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
e := &Error{Status: resp.StatusCode}
var msg struct {
Error string `json:"error"`
Message string `json:"message"`
}
if json.Unmarshal(data, &msg) == nil {
e.Code, e.Message = msg.Error, msg.Message
}
return nil, e
}
var reg Registered
if err := json.Unmarshal(data, &reg); err != nil {
return nil, fmt.Errorf("hub: bad answer: %v", err)
}
if !ValidProduct(reg.Product) || reg.Revision < 1 {
return nil, fmt.Errorf("hub: bad answer: product %q, revision %d", reg.Product, reg.Revision)
}
return &reg, nil
}