1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
package main

import (
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"errors"
	"fmt"
	"time"
)

// Asset kinds.
const (
	AssetImage = "image"
	AssetSTL   = "stl"
)

// Rendition variant names. These appear in asset URLs, so they are part of the
// interface between the renderer and the HTTP layer.
const (
	VariantOriginal = "original"
	VariantWebP800  = "webp-800"
	VariantWebP1600 = "webp-1600"
	VariantMesh     = "mesh"
)

// Rendition is a derived representation of an asset.
type Rendition struct {
	Variant string
	MIME    string
	Width   int
	Height  int
	Bytes   []byte
}

// storeAsset writes an upload and its renditions in one transaction.
//
// Assets are content-addressed: if these exact bytes are already stored, the
// existing asset is returned and nothing is written. Dropping the same image
// into three posts therefore costs one copy, and re-dropping a file you already
// used is free rather than a duplicate blob.
//
// Note this hashes the *original* bytes, not the derivatives, so the identity
// of an asset does not change when the encoder or its settings do.
func storeAsset(db *sql.DB, kind, mime, filename string, data []byte, width, height int, renditions []Rendition) (*Asset, error) {
	sum := sha256.Sum256(data)
	hash := hex.EncodeToString(sum[:])

	if a, err := assetBySHA(db, hash); err == nil {
		return a, nil
	} else if !errors.Is(err, ErrNotFound) {
		return nil, err
	}

	now := time.Now()
	tx, err := db.Begin()
	if err != nil {
		return nil, fmt.Errorf("begin store asset: %w", err)
	}
	defer tx.Rollback()

	res, err := tx.Exec(
		`INSERT INTO asset (sha256, kind, mime, filename, bytes, width, height, created_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
		hash, kind, mime, filename, data, width, height, now.Unix())
	if err != nil {
		return nil, fmt.Errorf("insert asset: %w", err)
	}
	id, err := res.LastInsertId()
	if err != nil {
		return nil, fmt.Errorf("asset id: %w", err)
	}
	for _, r := range renditions {
		if _, err := tx.Exec(
			`INSERT INTO asset_rendition (asset_id, variant, mime, width, height, bytes)
			 VALUES (?, ?, ?, ?, ?, ?)`,
			id, r.Variant, r.MIME, r.Width, r.Height, r.Bytes); err != nil {
			return nil, fmt.Errorf("insert rendition %q: %w", r.Variant, err)
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit store asset: %w", err)
	}
	return &Asset{
		ID: id, SHA256: hash, Kind: kind, MIME: mime, Filename: filename,
		Width: width, Height: height, CreatedAt: now,
	}, nil
}

func assetBySHA(db *sql.DB, hash string) (*Asset, error) {
	row := db.QueryRow(
		`SELECT id, sha256, kind, mime, filename, width, height, created_at
		   FROM asset WHERE sha256 = ?`, hash)
	return scanAsset(row)
}

func assetByID(db *sql.DB, id int64) (*Asset, error) {
	row := db.QueryRow(
		`SELECT id, sha256, kind, mime, filename, width, height, created_at
		   FROM asset WHERE id = ?`, id)
	return scanAsset(row)
}

func scanAsset(s scanner) (*Asset, error) {
	var a Asset
	var created int64
	err := s.Scan(&a.ID, &a.SHA256, &a.Kind, &a.MIME, &a.Filename, &a.Width, &a.Height, &created)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("scan asset: %w", err)
	}
	a.CreatedAt = time.Unix(created, 0)
	return &a, nil
}

// loadRendition fetches one derived representation. The special variant
// "original" reads the untouched upload out of the asset row itself.
func loadRendition(db *sql.DB, assetID int64, variant string) (*Rendition, error) {
	if variant == VariantOriginal {
		var r Rendition
		err := db.QueryRow(
			`SELECT mime, width, height, bytes FROM asset WHERE id = ?`, assetID,
		).Scan(&r.MIME, &r.Width, &r.Height, &r.Bytes)
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrNotFound
		}
		if err != nil {
			return nil, fmt.Errorf("load original %d: %w", assetID, err)
		}
		r.Variant = VariantOriginal
		return &r, nil
	}

	var r Rendition
	err := db.QueryRow(
		`SELECT variant, mime, width, height, bytes
		   FROM asset_rendition WHERE asset_id = ? AND variant = ?`,
		assetID, variant,
	).Scan(&r.Variant, &r.MIME, &r.Width, &r.Height, &r.Bytes)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("load rendition %d/%s: %w", assetID, variant, err)
	}
	return &r, nil
}