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
|
package main
import (
"bytes"
"database/sql"
"fmt"
"net/http"
"path/filepath"
"strings"
)
// Uploads
// ============================================================================
//
// One entry point for every dropped file: the type is detected from the bytes,
// the right pipeline runs, and the matching block kind is created. Which is
// what makes drag-and-drop feel like it "just knows" — the browser only sends
// bytes and a filename, and nothing about the UI needs to change when a new
// file type becomes supported.
// maxUploadBytes bounds a single uploaded file. 3D models and camera originals
// are the large cases; 64 MiB accommodates both while keeping a runaway or
// hostile upload from filling memory, since the whole file is buffered to hash
// and decode it.
const maxUploadBytes = 64 << 20
// detectedType is what sniffing an upload yields.
type detectedType struct {
// AssetKind is AssetImage or AssetSTL.
AssetKind string
// BlockKind is the block kind to create for it.
BlockKind string
MIME string
}
// sniffUpload determines what a file is, from its contents first and its name
// only as a tiebreaker.
//
// Content wins because a file's extension is a claim by whoever named it, and
// the pipeline that follows will attempt to decode the bytes regardless. STL
// has no magic number at all, so it is the one case where the extension is
// genuinely needed to distinguish "this is a model" from "this is some other
// binary"; even then the parser validates the structure before anything is
// stored.
func sniffUpload(filename string, data []byte) (*detectedType, error) {
ext := strings.ToLower(filepath.Ext(filename))
switch {
case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):
return &detectedType{AssetImage, KindImage, "image/png"}, nil
case bytes.HasPrefix(data, []byte("\xff\xd8\xff")):
return &detectedType{AssetImage, KindImage, "image/jpeg"}, nil
case len(data) >= 12 && bytes.Equal(data[0:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
return &detectedType{AssetImage, KindImage, "image/webp"}, nil
case ext == ".stl":
return &detectedType{AssetSTL, KindSTL, "model/stl"}, nil
}
// An extension-less or oddly-named STL still parses; give it a chance
// before rejecting the upload outright.
if _, err := parseSTL(data); err == nil {
return &detectedType{AssetSTL, KindSTL, "model/stl"}, nil
}
return nil, fmt.Errorf("unsupported file type %q (accepted: PNG, JPEG, WebP, STL)", filename)
}
// ingestUpload stores a file and creates the block that displays it.
//
// after is the position to insert behind (-1 for "first"), matching
// insertBlock: dropping a file onto a block puts the new block right after it.
func ingestUpload(db *sql.DB, postID int64, after int, filename string, data []byte) (*Block, error) {
if len(data) == 0 {
return nil, fmt.Errorf("file %q is empty", filename)
}
// Confirm the post exists before doing the expensive decode/encode work and
// before writing an asset, so a request naming a missing post cannot leave
// an orphaned blob behind.
if _, err := postByID(db, postID); err != nil {
return nil, err
}
det, err := sniffUpload(filename, data)
if err != nil {
return nil, err
}
var asset *Asset
switch det.AssetKind {
case AssetImage:
proc, err := processImage(data, det.MIME)
if err != nil {
return nil, fmt.Errorf("process %q: %w", filename, err)
}
asset, err = storeAsset(db, AssetImage, det.MIME, filename, data,
proc.Width, proc.Height, proc.Renditions)
if err != nil {
return nil, err
}
case AssetSTL:
m, err := parseSTL(data)
if err != nil {
return nil, fmt.Errorf("parse %q: %w", filename, err)
}
asset, err = storeAsset(db, AssetSTL, det.MIME, filename, data, 0, 0,
[]Rendition{{
Variant: VariantMesh,
MIME: "application/octet-stream",
Bytes: packMesh(m),
}})
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unhandled asset kind %q", det.AssetKind)
}
// The alt text starts empty on purpose rather than being pre-filled with
// the filename: a filename is not a description, and a wrong alt text is
// worse than a visibly missing one.
return insertBlock(db, postID, after, det.BlockKind, "", &asset.ID, BlockMeta{})
}
// readUploadPart reads one multipart file, refusing anything over the limit.
func readUploadPart(r *http.Request, field string) (filename string, data []byte, err error) {
file, header, err := r.FormFile(field)
if err != nil {
return "", nil, fmt.Errorf("read upload: %w", err)
}
defer file.Close()
var buf bytes.Buffer
// One byte over the limit is enough to detect the overrun without reading
// an unbounded amount into memory.
n, err := buf.ReadFrom(http.MaxBytesReader(nil, file, maxUploadBytes+1))
if err != nil {
return "", nil, fmt.Errorf("read upload body: %w", err)
}
if n > maxUploadBytes {
return "", nil, fmt.Errorf("file exceeds %d MiB limit", maxUploadBytes>>20)
}
// Browsers may send a path; keep only the base name.
return filepath.Base(header.Filename), buf.Bytes(), nil
}
|