package store import ( "context" "errors" "io" "path" "strings" "time" "github.com/google/uuid" ) // File is an upload in the blog's library: an image, a document, an archive… // The bytes are never loaded with it; FileReader streams them. type File struct { ID uuid.UUID Filename string ContentType string Kind string // image, document, audio, video, archive, other (CHECK in the schema) Size int64 CreatedAt time.Time } // Badge is the short label the dashboard shows instead of a thumbnail: the // extension in capitals, or FILE when there is none. func (f File) Badge() string { ext := strings.ToUpper(strings.TrimPrefix(path.Ext(f.Filename), ".")) if ext == "" || len([]rune(ext)) > 5 { return "FILE" } return ext } const fileCols = `id, filename, content_type, kind, size, created_at` func scanFile(row interface{ Scan(...any) error }) (*File, error) { var f File if err := row.Scan(&f.ID, &f.Filename, &f.ContentType, &f.Kind, &f.Size, &f.CreatedAt); err != nil { return nil, wrap(err) } return &f, nil } func (bs *BlogStore) CreateFile(ctx context.Context, filename, contentType, kind string, data []byte) (*File, error) { f := &File{ID: uuid.New(), Filename: filename, ContentType: contentType, Kind: kind, Size: int64(len(data))} err := bs.db.QueryRow(ctx, `INSERT INTO files (id, filename, content_type, kind, size, data) VALUES ($1,$2,$3,$4,$5,$6) RETURNING created_at`, f.ID, filename, contentType, kind, f.Size, data).Scan(&f.CreatedAt) return f, err } // likeEscape makes user text safe inside an ILIKE pattern. var likeEscape = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) // ListFiles is one page of the library, newest first, with the total that // matches the same filter. kind "" is every kind, q "" no filename filter and // limit 0 no limit (the design page's image picker). func (bs *BlogStore) ListFiles(ctx context.Context, kind, q string, limit, offset int) ([]File, int, error) { const where = ` WHERE ($1 = '' OR kind = $1) AND ($2 = '' OR filename ILIKE '%' || $2 || '%')` q = likeEscape.Replace(q) var total int if err := bs.db.QueryRow(ctx, `SELECT count(*) FROM files`+where, kind, q).Scan(&total); err != nil { return nil, 0, err } rows, err := bs.db.Query(ctx, `SELECT `+fileCols+` FROM files`+where+` ORDER BY created_at DESC, id DESC LIMIT NULLIF($3, 0) OFFSET $4`, kind, q, limit, offset) if err != nil { return nil, 0, err } defer rows.Close() var out []File for rows.Next() { f, err := scanFile(rows) if err != nil { return nil, 0, err } out = append(out, *f) } return out, total, rows.Err() } // GalleryImages lists the images a gallery page shows, newest first: every // library image but the page's hidden ones and except (the theme's logo and // favicon, which the caller knows). func (bs *BlogStore) GalleryImages(ctx context.Context, pageID int64, except []uuid.UUID) ([]File, error) { rows, err := bs.db.Query(ctx, `SELECT `+fileCols+` FROM files WHERE kind = 'image' AND id <> ALL($2) AND NOT EXISTS (SELECT 1 FROM page_hidden_files h WHERE h.page_id=$1 AND h.file_id=files.id) ORDER BY created_at DESC, id DESC`, pageID, except) if err != nil { return nil, err } defer rows.Close() var out []File for rows.Next() { f, err := scanFile(rows) if err != nil { return nil, err } out = append(out, *f) } return out, rows.Err() } // FileMeta loads a file without its bytes. func (bs *BlogStore) FileMeta(ctx context.Context, id uuid.UUID) (*File, error) { return scanFile(bs.db.QueryRow(ctx, `SELECT `+fileCols+` FROM files WHERE id=$1`, id)) } // FileReader streams a file's bytes in slices, so serving it never holds the // whole blob in memory (the column is STORAGE EXTERNAL: substring is cheap). func (bs *BlogStore) FileReader(ctx context.Context, f *File) io.ReadSeeker { return newChunkReader(f.Size, fileChunk, func(off, n int64) ([]byte, error) { var b []byte // substring is 1-based and its arguments are int4: the upload limit is capped so offsets fit. err := bs.db.QueryRow(ctx, `SELECT substring(data FROM $2 FOR $3) FROM files WHERE id=$1`, f.ID, int32(off+1), int32(n)).Scan(&b) return b, wrap(err) }) } func (bs *BlogStore) RenameFile(ctx context.Context, id uuid.UUID, filename string) error { tag, err := bs.db.Exec(ctx, `UPDATE files SET filename=$2 WHERE id=$1`, id, filename) if err == nil && tag.RowsAffected() == 0 { return ErrNotFound } return err } func (bs *BlogStore) DeleteFile(ctx context.Context, id uuid.UUID) error { _, err := bs.db.Exec(ctx, `DELETE FROM files WHERE id=$1`, id) return err } // FileUsage is what the library holds: how many files and their bytes. func (bs *BlogStore) FileUsage(ctx context.Context) (count int, bytes int64, err error) { err = bs.db.QueryRow(ctx, `SELECT count(*), COALESCE(sum(size), 0) FROM files`).Scan(&count, &bytes) return count, bytes, err } // fileChunk is how much of a file one query fetches: 20 round trips for a // 10 MB download, and a bounded buffer per request whatever the file size. const fileChunk = 512 << 10 // chunkReader is an io.ReadSeeker over bytes fetched in slices, which is what // http.ServeContent needs to stream a file and honour Range requests. fetch // returns data[off:off+n]. Seek never fetches: ServeContent seeks to the end // and back to learn the size before reading anything. type chunkReader struct { size, off int64 chunk int64 fetch func(off, n int64) ([]byte, error) buf []byte // data[bufOff : bufOff+len(buf)] bufOff int64 } func newChunkReader(size, chunk int64, fetch func(off, n int64) ([]byte, error)) *chunkReader { return &chunkReader{size: size, chunk: chunk, fetch: fetch} } func (r *chunkReader) Read(p []byte) (int, error) { if r.off >= r.size { return 0, io.EOF } if r.buf == nil || r.off < r.bufOff || r.off >= r.bufOff+int64(len(r.buf)) { n := min(r.chunk, r.size-r.off) b, err := r.fetch(r.off, n) if err != nil { return 0, err } if int64(len(b)) < n { // shorter than the row said: the file changed underneath us return 0, io.ErrUnexpectedEOF } r.buf, r.bufOff = b, r.off } n := copy(p, r.buf[r.off-r.bufOff:]) r.off += int64(n) return n, nil } func (r *chunkReader) Seek(offset int64, whence int) (int64, error) { var abs int64 switch whence { case io.SeekStart: abs = offset case io.SeekCurrent: abs = r.off + offset case io.SeekEnd: abs = r.size + offset default: return 0, errors.New("chunkReader: invalid whence") } if abs < 0 { return 0, errors.New("chunkReader: negative position") } r.off = abs return abs, nil }