// avatar.go is the uploaded mark: setting one, clearing one, and
// reading one back so a page can serve it.
//
// The bytes go to the FORGE's bucket, never a repository's own. A
// repository's binding is the steward's storage for that repository's
// attachments; a mark is the forge's furniture, shown on listings a
// reader of other repositories sees, and putting it in somebody's own
// bucket would make their unbinding a hole in a page that is not
// theirs.
//
// Which also means: with no forge bucket bound, there is nowhere to
// put one, and the op says so rather than half-succeeding. Everything
// keeps its identicon, which is what everything had before.
package api
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/s3"
)
// AvatarStore is the metadata half: the bytes are in the bucket.
type AvatarStore interface {
SetAvatar(a oltp.Avatar) error
AvatarOf(kind oltp.AvatarKind, name string) (oltp.Avatar, bool, error)
AvatarETags(kind oltp.AvatarKind) (map[string]string, error)
ClearAvatar(kind oltp.AvatarKind, name string) error
}
// maxAvatarBytes is what a mark may weigh. Generous for the job — a
// 512px image compresses well under this — and small enough that the
// forge holding it in memory to validate and to serve costs nothing.
const maxAvatarBytes = 512 << 10
// avatarTypes are the formats a browser draws without a plugin and a
// sniffer can recognise from its first bytes. SVG is deliberately
// absent: it is a document, it can carry script and remote references,
// and serving one from the forge's own origin would hand every page it
// appears on whatever the uploader put in it.
var avatarTypes = map[string]string{
"\x89PNG\r\n\x1a\n": "image/png",
"\xff\xd8\xff": "image/jpeg",
"GIF87a": "image/gif",
"GIF89a": "image/gif",
"RIFF": "image/webp", // checked further below
}
// sniffAvatar names the type from the BYTES, never from what the
// upload claimed. A declared type is the uploader's word for it, and
// this image is served from the forge's own origin to every reader.
func sniffAvatar(raw []byte) (string, error) {
for magic, mime := range avatarTypes {
if !strings.HasPrefix(string(raw), magic) {
continue
}
if mime == "image/webp" {
// RIFF alone is a container; WEBP is the form of it a
// browser draws.
if len(raw) < 12 || string(raw[8:12]) != "WEBP" {
continue
}
}
return mime, nil
}
return "", fmt.Errorf("not a PNG, JPEG, GIF or WebP image: %w", ops.ErrInvalid)
}
// avatarKey is where the object lives. The digest is in the key, so
// replacing a mark writes a NEW object rather than overwriting one a
// reader may be mid-fetch of, and a stale cache can never serve the
// wrong image for a key it already has.
func avatarKey(kind oltp.AvatarKind, name, sum string) string {
return "avatars/" + string(kind) + "/" + name + "/" + sum[:16]
}
// forgeBucketFor resolves the forge's own binding, or says why not.
func (d Deps) forgeBucketForAvatar() (s3.Bucket, error) {
if d.Buckets == nil || d.Avatars == nil {
return s3.Bucket{}, fmt.Errorf("this forge keeps no uploads: %w", ops.ErrUnavailable)
}
b, ok, err := d.Buckets.Bucket(oltp.ForgeBucket)
if err != nil {
return s3.Bucket{}, err
}
if !ok {
return s3.Bucket{}, fmt.Errorf(
"this forge supplies no storage, so it cannot hold an image: bind one on the fleet settings page: %w",
ops.ErrUnavailable)
}
return s3.Bucket{Endpoint: b.Endpoint, Name: b.Bucket, Region: b.Region,
AccessKey: b.AccessKey, Secret: b.Secret}, nil
}
type avatarSetIn struct {
Kind string `json:"kind" validate:"required,oneof=repo|team|user"`
Name string `json:"name" validate:"required,max=64"`
// Body is the image, base64. It crosses the op boundary as text
// because an op's input is JSON; the web layer hands it the bytes
// a multipart form gave it.
Body string `json:"body" validate:"required,max=1400000"`
}
type avatarOut struct {
OK bool `json:"ok"`
Mime string `json:"mime,omitempty"`
ETag string `json:"etag,omitempty"`
}
type avatarReadOut struct {
Mime string `json:"mime"`
ETag string `json:"etag"`
Body string `json:"body"`
}
type avatarClearIn struct {
Kind string `json:"kind" validate:"required,oneof=repo|team|user"`
Name string `json:"name" validate:"required,max=64"`
}
// mayDressAvatar answers who may change a mark: an admin of the
// repository, an owner of the team, or the person themselves. The same
// authority that already decides the thing's name and description — a
// mark is presentation, and presentation belongs with them.
//
// A person's own picture is nobody else's to set, not even a site
// admin's: it is how they are DRAWN, everywhere they appear, and an
// admin who could change it could make somebody look like somebody
// else. Removing an abusive one is account moderation and belongs
// with the account, not here.
func (d Deps) mayDressAvatar(ctx context.Context, kind oltp.AvatarKind, name string) bool {
switch kind {
case oltp.AvatarRepo:
return d.maySetRepoTeam(ctx, name) // `a` on the repository, or site admin
case oltp.AvatarTeam:
return d.mayManageTeam(ctx, name)
case oltp.AvatarUser:
return author(ctx) != "" && author(ctx) == name
}
return false
}
func (d Deps) registerAvatars(r *ops.Registry) error {
if err := ops.Register(r, ops.Op[avatarSetIn, avatarOut]{
Name: "avatar.set",
Doc: "Give a repository or a team an uploaded mark, replacing any it had. " +
"The image goes to the storage the FORGE supplies, not a repository's own binding: a mark is shown on listings other people read, so it must not vanish when somebody unbinds their bucket. " +
"PNG, JPEG, GIF or WebP, recognised from the bytes rather than from what the upload claimed, and at most 512 KiB.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/avatar",
Run: func(ctx context.Context, in avatarSetIn) (avatarOut, error) {
kind := oltp.AvatarKind(in.Kind)
if !d.mayDressAvatar(ctx, kind, in.Name) {
return avatarOut{}, fmt.Errorf(
"only an admin of a repository, or an owner of a team, may change its image: %w",
ops.ErrForbidden)
}
raw, err := base64.StdEncoding.DecodeString(in.Body)
if err != nil {
return avatarOut{}, fmt.Errorf("image: %w", ops.ErrInvalid)
}
if len(raw) == 0 {
return avatarOut{}, fmt.Errorf("the image is empty: %w", ops.ErrInvalid)
}
if len(raw) > maxAvatarBytes {
return avatarOut{}, fmt.Errorf("an image may be at most %d KiB: %w",
maxAvatarBytes>>10, ops.ErrInvalid)
}
mime, err := sniffAvatar(raw)
if err != nil {
return avatarOut{}, err
}
bucket, err := d.forgeBucketForAvatar()
if err != nil {
return avatarOut{}, err
}
sum := sha256.Sum256(raw)
etag := hex.EncodeToString(sum[:])
key := avatarKey(kind, in.Name, etag)
putCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
if err := bucket.Put(putCtx, &http.Client{Timeout: probeTimeout},
key, mime, raw, time.Now()); err != nil {
return avatarOut{}, fmt.Errorf("the storage refused the image: %v: %w",
err, ops.ErrUnavailable)
}
// Written only after the object is there: a row naming an
// object that does not exist is a broken image on every
// page it appears on.
if err := d.Avatars.SetAvatar(oltp.Avatar{
Kind: kind, Name: in.Name, Key: key, Mime: mime,
ETag: etag, Bytes: int64(len(raw)), SetBy: author(ctx),
}); err != nil {
return avatarOut{}, err
}
return avatarOut{OK: true, Mime: mime, ETag: etag}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[avatarClearIn, avatarReadOut]{
Name: "avatar.get",
Doc: "Read an uploaded mark's bytes, base64, so the FORGE can serve it. " +
"The page never links the bucket: quarry's privacy page promises the interface loads nothing from another host, and an image element pointed at the storage would break that for every reader of every page it appears on.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/avatar/{kind}/{name}",
Run: func(ctx context.Context, in avatarClearIn) (avatarReadOut, error) {
if d.Avatars == nil {
return avatarReadOut{}, fmt.Errorf("no mark: %w", ops.ErrNotFound)
}
kind := oltp.AvatarKind(in.Kind)
// A repository nobody may see has no mark to show either:
// an image is a smaller leak than a page and still a leak.
// A team is public by RFC 0018, and so is an account's
// picture — it is drawn beside every line they have
// written on every page those lines appear.
if kind == oltp.AvatarRepo {
if _, err := d.repoFor(ctx, in.Name); err != nil {
return avatarReadOut{}, err
}
}
a, ok, err := d.Avatars.AvatarOf(kind, in.Name)
if err != nil {
return avatarReadOut{}, err
}
if !ok {
return avatarReadOut{}, fmt.Errorf("no mark for %s %q: %w", in.Kind, in.Name, ops.ErrNotFound)
}
bucket, err := d.forgeBucketForAvatar()
if err != nil {
return avatarReadOut{}, err
}
getCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
raw, _, err := bucket.Get(getCtx, &http.Client{Timeout: probeTimeout}, a.Key, time.Now())
if err != nil {
return avatarReadOut{}, fmt.Errorf("the storage did not return the image: %v: %w",
err, ops.ErrUnavailable)
}
// The stored type, not whatever the bucket answered with:
// it was sniffed from the bytes when it was accepted, and
// a bucket's Content-Type is metadata anybody with the key
// could have set.
return avatarReadOut{Mime: a.Mime, ETag: a.ETag,
Body: base64.StdEncoding.EncodeToString(raw)}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[avatarClearIn, avatarOut]{
Name: "avatar.clear",
Doc: "Take away an uploaded mark; the identicon derived from the name comes back. " +
"The object is left in the forge's storage — a removal is a decision about the page and a deletion one about the bytes (RFC 0016).",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/avatar/clear",
Run: func(ctx context.Context, in avatarClearIn) (avatarOut, error) {
if d.Avatars == nil {
return avatarOut{}, fmt.Errorf("this forge keeps no uploads: %w", ops.ErrUnavailable)
}
kind := oltp.AvatarKind(in.Kind)
if !d.mayDressAvatar(ctx, kind, in.Name) {
return avatarOut{}, fmt.Errorf(
"only an admin of a repository, or an owner of a team, may change its image: %w",
ops.ErrForbidden)
}
if err := d.Avatars.ClearAvatar(kind, in.Name); err != nil {
return avatarOut{}, err
}
return avatarOut{OK: true}, nil
},
})
}