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]>
114 lines
3.1 KiB
Go
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, ®); 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 ®, nil
|
|
}
|