Phase 6: media
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s

Implements PLAN.md Phase 6: the media repository. Clients upload files,
reserve an mxc:// URI and upload to it later, download and ask for
thumbnails, all through the authenticated media endpoints of Matrix 1.11;
other servers download this server's media through the federation media
endpoints, and media uploaded elsewhere is fetched over federation on
first use and kept.

internal/mediaapi is the component. Content goes into a BlobStore named
by its SHA-256, so the same file uploaded twice is stored once and the
hash doubles as the ETag; the metadata that maps an mxc:// URI onto a
blob is in media_repository and media_thumbnails. The local-filesystem
store writes to a temporary file and renames it into place once hashed,
so nothing ever refers to half a file. A storage driver can bring its own
store through storage.BlobProvider, which is where PocketBase's
filesystem abstraction (local or S3) plugs in with Phase 10.

Reserved uploads belong to the user who made them, take one upload,
lapse after a day, and are capped per user; a download of one waits for
its content, woken in memory the moment it arrives, for as long as
timeout_ms allows.

Thumbnails come from a fixed set of configured sizes, so a thousand
requested sizes cannot mean a thousand resizes. They are made on first
request and kept, never enlarge, honour a JPEG's EXIF orientation, and
point at the original when it already fits. The pixel count is checked
from the header before anything is decoded, at most four resizes run at
once, and concurrent requests for the same thumbnail share one.

Remote media is fetched through the signed client: the multipart/mixed
answer of Matrix 1.11, with a fallback to the legacy download endpoint
for servers that answer M_UNRECOGNIZED, and Location redirects followed
by a dialer that refuses loopback, private and link-local addresses.
Concurrent requests share one fetch, detached from the request that
started it. With federation off nothing is fetched, and what was cached
before is still served.

Downloads carry a sandboxing CSP, nosniff, inline Content-Disposition
only for the types the spec lists as safe, and private immutable caching.
The unauthenticated legacy download endpoints are off by default behind
media.legacy_unauthenticated_downloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-16 21:26:49 +02:00
1 parent 6aaf554336
commit 92836f8dcd
40 files changed
+4729 -63

No files matched your search

@@ -0,0 +1,159 @@
// Package filestore is the local-filesystem storage.BlobStore: media
// content as files under one directory, each named by its SHA-256.
//
// A blob lives at <root>/ab/cd/abcd…, two levels of fan-out so that no
// directory grows past a few thousand entries. Content is written to
// <root>/tmp first and renamed into place once its hash is known, so a
// reader never sees half a file and a crash leaves nothing behind but a
// temporary file.
package filestore
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Store is a BlobStore rooted at a directory.
type Store struct {
root string
}
// New returns a store rooted at dir. Nothing is created until the first
// Put: a server that never stores media leaves no directory behind.
func New(dir string) (*Store, error) {
if dir == "" {
return nil, errors.New("filestore: directory must not be empty")
}
abs, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("filestore: %w", err)
}
return &Store{root: abs}, nil
}
// Root is the directory the store writes under.
func (s *Store) Root() string { return s.root }
// path is where a blob lives. It refuses anything that is not a SHA-256 in
// lower-case hex, which is also what keeps a hash read from a database row
// from naming a file outside the store.
func (s *Store) path(sum string) (string, bool) {
if len(sum) != sha256.Size*2 {
return "", false
}
for _, c := range sum {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return "", false
}
}
return filepath.Join(s.root, sum[0:2], sum[2:4], sum), true
}
// Put implements storage.BlobStore.
func (s *Store) Put(ctx context.Context, r io.Reader, limit int64) (storage.BlobInfo, error) {
tmpDir := filepath.Join(s.root, "tmp")
if err := os.MkdirAll(tmpDir, 0o750); err != nil {
return storage.BlobInfo{}, fmt.Errorf("create the media directory: %w", err)
}
tmp, err := os.CreateTemp(tmpDir, "upload-*")
if err != nil {
return storage.BlobInfo{}, fmt.Errorf("create a temporary file: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
}
}()
hash := sha256.New()
n, err := io.Copy(io.MultiWriter(tmp, hash), &ctxReader{ctx: ctx, r: io.LimitReader(r, limit+1)})
if err != nil {
return storage.BlobInfo{}, fmt.Errorf("store content: %w", err)
}
if n > limit {
return storage.BlobInfo{}, storage.ErrBlobTooLarge
}
if err := tmp.Sync(); err != nil {
return storage.BlobInfo{}, fmt.Errorf("store content: %w", err)
}
if err := tmp.Close(); err != nil {
return storage.BlobInfo{}, fmt.Errorf("store content: %w", err)
}
info := storage.BlobInfo{SHA256: hex.EncodeToString(hash.Sum(nil)), Size: n}
final, _ := s.path(info.SHA256)
if _, err := os.Stat(final); err == nil {
// The same content is already stored: the copy just written is
// the same bytes, and is dropped.
return info, nil
}
if err := os.MkdirAll(filepath.Dir(final), 0o750); err != nil {
return storage.BlobInfo{}, fmt.Errorf("create the media directory: %w", err)
}
if err := os.Rename(tmp.Name(), final); err != nil {
// Another Put of the same content may have got there first,
// which on Windows makes the rename fail rather than replace.
if _, statErr := os.Stat(final); statErr == nil {
return info, nil
}
return storage.BlobInfo{}, fmt.Errorf("store content: %w", err)
}
committed = true
return info, nil
}
// Open implements storage.BlobStore.
func (s *Store) Open(_ context.Context, sum string) (io.ReadSeekCloser, error) {
p, ok := s.path(sum)
if !ok {
return nil, storage.ErrNoSuchBlob
}
f, err := os.Open(p)
if errors.Is(err, fs.ErrNotExist) {
return nil, storage.ErrNoSuchBlob
}
if err != nil {
return nil, fmt.Errorf("open media content: %w", err)
}
return f, nil
}
// Delete implements storage.BlobStore.
func (s *Store) Delete(_ context.Context, sum string) error {
p, ok := s.path(sum)
if !ok {
// Not a hash, so not the name of anything stored.
return nil
}
if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("delete media content: %w", err)
}
return nil
}
// ctxReader stops a copy when its context ends: an upload from a client
// that has gone away should not keep writing to disk.
type ctxReader struct {
ctx context.Context
r io.Reader
}
func (c *ctxReader) Read(p []byte) (int, error) {
if err := c.ctx.Err(); err != nil {
return 0, err
}
return c.r.Read(p)
}
var _ storage.BlobStore = (*Store)(nil)
+149
View File
@@ -0,0 +1,149 @@
package storage
import (
"context"
"errors"
"io"
)
// Errors of the media store.
var (
// ErrNoSuchMedia means no media, or no thumbnail, is stored under the
// given name.
ErrNoSuchMedia = errors.New("no such media")
// ErrMediaExists is CreateMedia's answer for a media ID that is taken.
ErrMediaExists = errors.New("media already exists")
// ErrMediaUploaded is CompleteUpload's answer for media whose content
// has already been uploaded: content, once stored, never changes.
ErrMediaUploaded = errors.New("media content already uploaded")
)
// Media is the metadata of one piece of content: uploaded here, or fetched
// from the server it was uploaded to and kept.
type Media struct {
// Origin is the server name in the mxc:// URI: this server for an
// upload, another for cached remote media.
Origin string
MediaID string
// ContentType is the Content-Type the content was uploaded with,
// parameters included.
ContentType string
// UploadName is the file name the uploader gave, or "".
UploadName string
// Size is the content's length in bytes.
Size int64
// SHA256 names the content in the blob store: its SHA-256, in
// lower-case hex. Empty while the media is pending.
SHA256 string
// UserID is the uploader; "" for remote media.
UserID string
CreatedAtMS int64
// Pending is set for an ID handed out by /create whose content has
// not been uploaded yet. Pending media expires at ExpiresAtMS.
Pending bool
ExpiresAtMS int64
}
// MediaContent is what CompleteUpload fills in on pending media.
type MediaContent struct {
ContentType string
UploadName string
Size int64
SHA256 string
}
// Thumbnail is one generated (or, for remote media, fetched) thumbnail.
type Thumbnail struct {
Origin string
MediaID string
// Width, Height and Method are the size the thumbnail was made for —
// one of the configured sizes — not the size of the image itself,
// which for "scale" keeps the original's aspect ratio.
Width int
Height int
Method string
ContentType string
Size int64
SHA256 string
CreatedAtMS int64
}
// MediaStore is the mediaapi store: the metadata of local and cached
// remote media and of their thumbnails. The content itself is in a
// BlobStore.
type MediaStore interface {
// CreateMedia records media: an upload, a pending upload or a remote
// fetch. A taken (origin, media ID) is ErrMediaExists.
CreateMedia(ctx context.Context, m Media) error
// Media reads one media record, pending or not.
Media(ctx context.Context, origin, mediaID string) (Media, error)
// CompleteUpload stores the content of pending media and clears its
// pending flag. It is ErrNoSuchMedia for an unknown ID and
// ErrMediaUploaded for media that is not pending.
CompleteUpload(ctx context.Context, origin, mediaID string, content MediaContent) error
// PendingUploads counts a user's pending media that has not expired
// by nowMS.
PendingUploads(ctx context.Context, userID string, nowMS int64) (int, error)
// DeleteExpiredUploads removes pending media that expired before
// nowMS, reporting how many.
DeleteExpiredUploads(ctx context.Context, nowMS int64) (int, error)
// Thumbnail reads the thumbnail made for one size.
Thumbnail(ctx context.Context, origin, mediaID string, width, height int, method string) (Thumbnail, error)
// StoreThumbnail records a thumbnail, replacing one stored for the
// same size.
StoreThumbnail(ctx context.Context, t Thumbnail) error
}
// ErrNoSuchBlob means the blob store holds nothing under a hash.
var ErrNoSuchBlob = errors.New("no such blob")
// ErrBlobTooLarge means content passed the limit it was stored with.
var ErrBlobTooLarge = errors.New("content is larger than allowed")
// BlobInfo describes stored content.
type BlobInfo struct {
// SHA256 is the lower-case hex SHA-256 of the content, and its name in
// the store.
SHA256 string
Size int64
}
// BlobStore keeps media content, addressed by its SHA-256. The same bytes
// uploaded twice are stored once.
type BlobStore interface {
// Put stores what r yields, up to limit bytes; more is
// ErrBlobTooLarge and stores nothing.
Put(ctx context.Context, r io.Reader, limit int64) (BlobInfo, error)
// Open reads stored content.
Open(ctx context.Context, sha256 string) (io.ReadSeekCloser, error)
// Delete removes stored content. Deleting what is not there is not an
// error.
Delete(ctx context.Context, sha256 string) error
}
// BlobProvider is implemented by a storage driver that brings its own
// BlobStore. PocketBase will (PLAN.md section 4: its filesystem
// abstraction puts media on local disk or S3); a driver that does not is
// given the local filesystem store.
type BlobProvider interface {
Blobs() BlobStore
}
+225
View File
@@ -0,0 +1,225 @@
package storage_test
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"io"
"os"
"strings"
"testing"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/storage/filestore"
)
// migratedMediaDB opens a driver with the media tables empty.
func migratedMediaDB(t *testing.T, driver string) storage.Database {
t.Helper()
db := openTestDB(t, driver)
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("Migrate: %v", err)
}
if driver == storage.DriverPostgres {
pool, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN"))
if err != nil {
t.Fatalf("open postgres to reset the media tables: %v", err)
}
defer func() { _ = pool.Close() }()
if _, err := pool.ExecContext(t.Context(), `TRUNCATE media_repository, media_thumbnails`); err != nil {
t.Fatalf("reset the media tables: %v", err)
}
}
return db
}
func TestMediaUploadAndRead(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
media := migratedMediaDB(t, driver).Media()
m := storage.Media{
Origin: "warp.example", MediaID: "abc", ContentType: "image/png",
UploadName: "cat.png", Size: 42, SHA256: strings.Repeat("a", 64),
UserID: "@alice:warp.example", CreatedAtMS: 1000,
}
if err := media.CreateMedia(ctx, m); err != nil {
t.Fatalf("CreateMedia: %v", err)
}
if err := media.CreateMedia(ctx, m); !errors.Is(err, storage.ErrMediaExists) {
t.Fatalf("CreateMedia twice = %v, want ErrMediaExists", err)
}
got, err := media.Media(ctx, "warp.example", "abc")
if err != nil {
t.Fatalf("Media: %v", err)
}
if got != m {
t.Errorf("Media = %+v, want %+v", got, m)
}
if _, err := media.Media(ctx, "other.example", "abc"); !errors.Is(err, storage.ErrNoSuchMedia) {
t.Errorf("Media of another origin = %v, want ErrNoSuchMedia", err)
}
// Content, once stored, does not change.
err = media.CompleteUpload(ctx, "warp.example", "abc", storage.MediaContent{SHA256: strings.Repeat("b", 64)})
if !errors.Is(err, storage.ErrMediaUploaded) {
t.Errorf("CompleteUpload of uploaded media = %v, want ErrMediaUploaded", err)
}
err = media.CompleteUpload(ctx, "warp.example", "nope", storage.MediaContent{})
if !errors.Is(err, storage.ErrNoSuchMedia) {
t.Errorf("CompleteUpload of unknown media = %v, want ErrNoSuchMedia", err)
}
})
}
}
func TestMediaPendingUploads(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
media := migratedMediaDB(t, driver).Media()
const alice = "@alice:warp.example"
for _, p := range []struct {
id string
expires int64
}{{"p1", 5000}, {"p2", 5000}, {"old", 1500}} {
if err := media.CreateMedia(ctx, storage.Media{
Origin: "warp.example", MediaID: p.id, UserID: alice,
CreatedAtMS: 1000, Pending: true, ExpiresAtMS: p.expires,
}); err != nil {
t.Fatalf("CreateMedia(%s): %v", p.id, err)
}
}
n, err := media.PendingUploads(ctx, alice, 2000)
if err != nil {
t.Fatalf("PendingUploads: %v", err)
}
if n != 2 {
t.Errorf("PendingUploads = %d, want 2 (one has expired)", n)
}
content := storage.MediaContent{
ContentType: "text/plain", UploadName: "a.txt", Size: 3, SHA256: strings.Repeat("c", 64),
}
if err := media.CompleteUpload(ctx, "warp.example", "p1", content); err != nil {
t.Fatalf("CompleteUpload: %v", err)
}
got, err := media.Media(ctx, "warp.example", "p1")
if err != nil {
t.Fatalf("Media: %v", err)
}
if got.Pending || got.ExpiresAtMS != 0 || got.SHA256 != content.SHA256 || got.Size != 3 {
t.Errorf("completed media = %+v", got)
}
if err := media.CompleteUpload(ctx, "warp.example", "p1", content); !errors.Is(err, storage.ErrMediaUploaded) {
t.Errorf("second CompleteUpload = %v, want ErrMediaUploaded", err)
}
deleted, err := media.DeleteExpiredUploads(ctx, 2000)
if err != nil {
t.Fatalf("DeleteExpiredUploads: %v", err)
}
if deleted != 1 {
t.Errorf("DeleteExpiredUploads = %d, want 1", deleted)
}
if _, err := media.Media(ctx, "warp.example", "old"); !errors.Is(err, storage.ErrNoSuchMedia) {
t.Errorf("expired upload still there: %v", err)
}
if _, err := media.Media(ctx, "warp.example", "p1"); err != nil {
t.Errorf("a completed upload was swept: %v", err)
}
})
}
}
func TestMediaThumbnails(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
media := migratedMediaDB(t, driver).Media()
th := storage.Thumbnail{
Origin: "remote.example", MediaID: "xyz", Width: 96, Height: 96, Method: "crop",
ContentType: "image/jpeg", Size: 10, SHA256: strings.Repeat("d", 64), CreatedAtMS: 7,
}
if _, err := media.Thumbnail(ctx, "remote.example", "xyz", 96, 96, "crop"); !errors.Is(err, storage.ErrNoSuchMedia) {
t.Fatalf("Thumbnail before storing = %v, want ErrNoSuchMedia", err)
}
if err := media.StoreThumbnail(ctx, th); err != nil {
t.Fatalf("StoreThumbnail: %v", err)
}
th.SHA256, th.Size = strings.Repeat("e", 64), 11
if err := media.StoreThumbnail(ctx, th); err != nil {
t.Fatalf("StoreThumbnail again: %v", err)
}
got, err := media.Thumbnail(ctx, "remote.example", "xyz", 96, 96, "crop")
if err != nil {
t.Fatalf("Thumbnail: %v", err)
}
if got != th {
t.Errorf("Thumbnail = %+v, want %+v", got, th)
}
if _, err := media.Thumbnail(ctx, "remote.example", "xyz", 96, 96, "scale"); !errors.Is(err, storage.ErrNoSuchMedia) {
t.Errorf("Thumbnail of another method = %v, want ErrNoSuchMedia", err)
}
})
}
}
func TestFileStore(t *testing.T) {
ctx := t.Context()
store, err := filestore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
content := []byte("hello, media")
sum := sha256.Sum256(content)
info, err := store.Put(ctx, bytes.NewReader(content), 100)
if err != nil {
t.Fatalf("Put: %v", err)
}
if info.SHA256 != hex.EncodeToString(sum[:]) || info.Size != int64(len(content)) {
t.Fatalf("Put = %+v", info)
}
// The same content again is the same blob.
again, err := store.Put(ctx, bytes.NewReader(content), 100)
if err != nil || again != info {
t.Fatalf("Put twice = %+v, %v", again, err)
}
f, err := store.Open(ctx, info.SHA256)
if err != nil {
t.Fatalf("Open: %v", err)
}
got, _ := io.ReadAll(f)
_ = f.Close()
if !bytes.Equal(got, content) {
t.Errorf("Open read %q", got)
}
if _, err := store.Put(ctx, bytes.NewReader(content), 5); !errors.Is(err, storage.ErrBlobTooLarge) {
t.Errorf("Put past the limit = %v, want ErrBlobTooLarge", err)
}
// A hash that is not one cannot name a file, inside the store or out.
for _, bad := range []string{"../../etc/passwd", strings.Repeat("G", 64), ""} {
if _, err := store.Open(ctx, bad); !errors.Is(err, storage.ErrNoSuchBlob) {
t.Errorf("Open(%q) = %v, want ErrNoSuchBlob", bad, err)
}
}
if err := store.Delete(ctx, info.SHA256); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := store.Open(ctx, info.SHA256); !errors.Is(err, storage.ErrNoSuchBlob) {
t.Errorf("Open after Delete = %v, want ErrNoSuchBlob", err)
}
if err := store.Delete(ctx, info.SHA256); err != nil {
t.Errorf("Delete twice = %v", err)
}
}
@@ -63,6 +63,9 @@ func (*Database) Sync() storage.SyncStore { return notImplementedSync{} }
// Federation implements storage.Database.
func (*Database) Federation() storage.FederationStore { return notImplementedFederation{} }
// Media implements storage.Database.
func (*Database) Media() storage.MediaStore { return notImplementedMedia{} }
// Close implements storage.Database.
func (*Database) Close() error { return nil }
@@ -431,6 +434,39 @@ func (notImplementedSync) Filter(context.Context, string, string) ([]byte, error
return nil, storage.ErrNotImplemented
}
// notImplementedMedia satisfies storage.MediaStore the same way. An upload
// whose metadata was not stored would hand the client an mxc:// URI that
// refers to nothing.
type notImplementedMedia struct{}
func (notImplementedMedia) CreateMedia(context.Context, storage.Media) error {
return storage.ErrNotImplemented
}
func (notImplementedMedia) Media(context.Context, string, string) (storage.Media, error) {
return storage.Media{}, storage.ErrNotImplemented
}
func (notImplementedMedia) CompleteUpload(context.Context, string, string, storage.MediaContent) error {
return storage.ErrNotImplemented
}
func (notImplementedMedia) PendingUploads(context.Context, string, int64) (int, error) {
return 0, storage.ErrNotImplemented
}
func (notImplementedMedia) DeleteExpiredUploads(context.Context, int64) (int, error) {
return 0, storage.ErrNotImplemented
}
func (notImplementedMedia) Thumbnail(context.Context, string, string, int, int, string) (storage.Thumbnail, error) {
return storage.Thumbnail{}, storage.ErrNotImplemented
}
func (notImplementedMedia) StoreThumbnail(context.Context, storage.Thumbnail) error {
return storage.ErrNotImplemented
}
// Compile-time proof that the stub keeps up with the interfaces.
var (
_ storage.Database = (*Database)(nil)
@@ -438,4 +474,5 @@ var (
_ storage.RoomStore = notImplementedRooms{}
_ storage.SyncStore = notImplementedSync{}
_ storage.FederationStore = notImplementedFederation{}
_ storage.MediaStore = notImplementedMedia{}
)
@@ -0,0 +1,56 @@
-- Media: the metadata of content uploaded here and of content fetched from
-- other servers and kept, and of the thumbnails made from either.
--
-- The content itself is not in the database. It is in the blob store —
-- files under media.base_path by default — named by its SHA-256, so the
-- same bytes uploaded twice are stored once and a row here only says which
-- blob an mxc:// URI refers to.
--
-- Timestamps are milliseconds since the Unix epoch, as everywhere else in
-- Warp.
CREATE TABLE IF NOT EXISTS media_repository (
-- The server name of the mxc:// URI: this server for an upload,
-- another for cached remote media.
media_origin TEXT NOT NULL,
media_id TEXT NOT NULL,
-- As uploaded, parameters included: "text/plain; charset=utf-8".
content_type TEXT NOT NULL DEFAULT '',
upload_name TEXT NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0,
-- Lower-case hex SHA-256 of the content; '' while pending.
sha256 TEXT NOT NULL DEFAULT '',
-- The uploader; '' for remote media.
user_id TEXT NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
-- An ID handed out by POST /_matrix/media/v1/create whose content has
-- not been uploaded yet. It is deleted unused at expires_at_ms.
pending BOOLEAN NOT NULL DEFAULT FALSE,
expires_at_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (media_origin, media_id)
);
-- Counting a user's pending uploads, and sweeping the expired ones.
CREATE INDEX IF NOT EXISTS media_repository_pending_idx
ON media_repository (pending, user_id, expires_at_ms);
-- Thumbnails, one per configured size that has been asked for.
--
-- There is no foreign key to media_repository: the thumbnail of remote
-- media is fetched from its server on its own, and the original it was
-- made from may never have been fetched here at all.
CREATE TABLE IF NOT EXISTS media_thumbnails (
media_origin TEXT NOT NULL,
media_id TEXT NOT NULL,
-- The configured size the thumbnail answers, not the size of the
-- image: a "scale" thumbnail keeps the original's aspect ratio.
width INTEGER NOT NULL,
height INTEGER NOT NULL,
-- 'crop' or 'scale'.
method TEXT NOT NULL,
content_type TEXT NOT NULL,
file_size BIGINT NOT NULL,
sha256 TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (media_origin, media_id, width, height, method)
);
@@ -0,0 +1,56 @@
-- Media: the metadata of content uploaded here and of content fetched from
-- other servers and kept, and of the thumbnails made from either.
--
-- The content itself is not in the database. It is in the blob store —
-- files under media.base_path by default — named by its SHA-256, so the
-- same bytes uploaded twice are stored once and a row here only says which
-- blob an mxc:// URI refers to.
--
-- Timestamps are milliseconds since the Unix epoch, as everywhere else in
-- Warp.
CREATE TABLE IF NOT EXISTS media_repository (
-- The server name of the mxc:// URI: this server for an upload,
-- another for cached remote media.
media_origin TEXT NOT NULL,
media_id TEXT NOT NULL,
-- As uploaded, parameters included: "text/plain; charset=utf-8".
content_type TEXT NOT NULL DEFAULT '',
upload_name TEXT NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0,
-- Lower-case hex SHA-256 of the content; '' while pending.
sha256 TEXT NOT NULL DEFAULT '',
-- The uploader; '' for remote media.
user_id TEXT NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
-- An ID handed out by POST /_matrix/media/v1/create whose content has
-- not been uploaded yet. It is deleted unused at expires_at_ms.
pending BOOLEAN NOT NULL DEFAULT 0,
expires_at_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (media_origin, media_id)
);
-- Counting a user's pending uploads, and sweeping the expired ones.
CREATE INDEX IF NOT EXISTS media_repository_pending_idx
ON media_repository (pending, user_id, expires_at_ms);
-- Thumbnails, one per configured size that has been asked for.
--
-- There is no foreign key to media_repository: the thumbnail of remote
-- media is fetched from its server on its own, and the original it was
-- made from may never have been fetched here at all.
CREATE TABLE IF NOT EXISTS media_thumbnails (
media_origin TEXT NOT NULL,
media_id TEXT NOT NULL,
-- The configured size the thumbnail answers, not the size of the
-- image: a "scale" thumbnail keeps the original's aspect ratio.
width INTEGER NOT NULL,
height INTEGER NOT NULL,
-- 'crop' or 'scale'.
method TEXT NOT NULL,
content_type TEXT NOT NULL,
file_size BIGINT NOT NULL,
sha256 TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (media_origin, media_id, width, height, method)
);
+174
View File
@@ -0,0 +1,174 @@
package sqlstore
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Media is the shared SQL implementation of storage.MediaStore.
type Media struct {
db *sql.DB
rebind func(string) string
isUnique func(error) bool
}
// NewMedia builds a media store.
func NewMedia(db *sql.DB, rebind func(string) string, isUnique func(error) bool) *Media {
if rebind == nil {
rebind = func(query string) string { return query }
}
if isUnique == nil {
isUnique = func(error) bool { return false }
}
return &Media{db: db, rebind: rebind, isUnique: isUnique}
}
func (s *Media) q(query string) string { return s.rebind(query) }
const insertMediaSQL = `
INSERT INTO media_repository
(media_origin, media_id, content_type, upload_name, file_size, sha256,
user_id, created_at_ms, pending, expires_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
// CreateMedia implements storage.MediaStore.
func (s *Media) CreateMedia(ctx context.Context, m storage.Media) error {
_, err := s.db.ExecContext(ctx, s.q(insertMediaSQL),
m.Origin, m.MediaID, m.ContentType, m.UploadName, m.Size, m.SHA256,
m.UserID, m.CreatedAtMS, m.Pending, m.ExpiresAtMS)
switch {
case err == nil:
return nil
case s.isUnique(err):
return storage.ErrMediaExists
default:
return fmt.Errorf("store media %s/%s: %w", m.Origin, m.MediaID, err)
}
}
const selectMediaSQL = `
SELECT media_origin, media_id, content_type, upload_name, file_size, sha256,
user_id, created_at_ms, pending, expires_at_ms
FROM media_repository WHERE media_origin = ? AND media_id = ?`
// Media implements storage.MediaStore.
func (s *Media) Media(ctx context.Context, origin, mediaID string) (storage.Media, error) {
var m storage.Media
err := s.db.QueryRowContext(ctx, s.q(selectMediaSQL), origin, mediaID).Scan(
&m.Origin, &m.MediaID, &m.ContentType, &m.UploadName, &m.Size, &m.SHA256,
&m.UserID, &m.CreatedAtMS, &m.Pending, &m.ExpiresAtMS)
switch {
case err == nil:
return m, nil
case errors.Is(err, sql.ErrNoRows):
return storage.Media{}, storage.ErrNoSuchMedia
default:
return storage.Media{}, fmt.Errorf("read media %s/%s: %w", origin, mediaID, err)
}
}
// completeUploadSQL only touches pending media, so that two uploads racing
// for the same ID cannot both win: the second matches no row.
const completeUploadSQL = `
UPDATE media_repository
SET content_type = ?, upload_name = ?, file_size = ?, sha256 = ?,
pending = ?, expires_at_ms = 0
WHERE media_origin = ? AND media_id = ? AND pending = ?`
// CompleteUpload implements storage.MediaStore.
func (s *Media) CompleteUpload(ctx context.Context, origin, mediaID string, c storage.MediaContent) error {
res, err := s.db.ExecContext(ctx, s.q(completeUploadSQL),
c.ContentType, c.UploadName, c.Size, c.SHA256, false, origin, mediaID, true)
if err != nil {
return fmt.Errorf("complete upload %s/%s: %w", origin, mediaID, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("complete upload %s/%s: %w", origin, mediaID, err)
}
if n == 1 {
return nil
}
// Nothing pending matched: either there is no such media, or its
// content is already there.
if _, err := s.Media(ctx, origin, mediaID); err != nil {
return err
}
return storage.ErrMediaUploaded
}
const countPendingSQL = `
SELECT COUNT(*) FROM media_repository
WHERE pending = ? AND user_id = ? AND expires_at_ms > ?`
// PendingUploads implements storage.MediaStore.
func (s *Media) PendingUploads(ctx context.Context, userID string, nowMS int64) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx, s.q(countPendingSQL), true, userID, nowMS).Scan(&n); err != nil {
return 0, fmt.Errorf("count the pending uploads of %s: %w", userID, err)
}
return n, nil
}
const deleteExpiredSQL = `
DELETE FROM media_repository WHERE pending = ? AND expires_at_ms <= ?`
// DeleteExpiredUploads implements storage.MediaStore.
func (s *Media) DeleteExpiredUploads(ctx context.Context, nowMS int64) (int, error) {
res, err := s.db.ExecContext(ctx, s.q(deleteExpiredSQL), true, nowMS)
if err != nil {
return 0, fmt.Errorf("delete expired uploads: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("delete expired uploads: %w", err)
}
return int(n), nil
}
const selectThumbnailSQL = `
SELECT media_origin, media_id, width, height, method, content_type,
file_size, sha256, created_at_ms
FROM media_thumbnails
WHERE media_origin = ? AND media_id = ? AND width = ? AND height = ? AND method = ?`
// Thumbnail implements storage.MediaStore.
func (s *Media) Thumbnail(ctx context.Context, origin, mediaID string, width, height int, method string) (storage.Thumbnail, error) {
var t storage.Thumbnail
err := s.db.QueryRowContext(ctx, s.q(selectThumbnailSQL), origin, mediaID, width, height, method).Scan(
&t.Origin, &t.MediaID, &t.Width, &t.Height, &t.Method, &t.ContentType,
&t.Size, &t.SHA256, &t.CreatedAtMS)
switch {
case err == nil:
return t, nil
case errors.Is(err, sql.ErrNoRows):
return storage.Thumbnail{}, storage.ErrNoSuchMedia
default:
return storage.Thumbnail{}, fmt.Errorf("read a thumbnail of %s/%s: %w", origin, mediaID, err)
}
}
const upsertThumbnailSQL = `
INSERT INTO media_thumbnails
(media_origin, media_id, width, height, method, content_type,
file_size, sha256, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (media_origin, media_id, width, height, method) DO UPDATE SET
content_type = excluded.content_type,
file_size = excluded.file_size,
sha256 = excluded.sha256,
created_at_ms = excluded.created_at_ms`
// StoreThumbnail implements storage.MediaStore.
func (s *Media) StoreThumbnail(ctx context.Context, t storage.Thumbnail) error {
if _, err := s.db.ExecContext(ctx, s.q(upsertThumbnailSQL),
t.Origin, t.MediaID, t.Width, t.Height, t.Method, t.ContentType,
t.Size, t.SHA256, t.CreatedAtMS); err != nil {
return fmt.Errorf("store a thumbnail of %s/%s: %w", t.Origin, t.MediaID, err)
}
return nil
}
@@ -60,6 +60,7 @@ type Store struct {
rooms *Rooms
sync *Sync
fed *Federation
media *Media
}
// New builds a Store. It does not touch the database.
@@ -79,6 +80,7 @@ func New(opts Options) (*Store, error) {
rooms: NewRooms(opts.DB, opts.Rebind, opts.IsUniqueViolation),
sync: NewSync(opts.DB, opts.Rebind, opts.IsUniqueViolation),
fed: NewFederation(opts.DB, opts.Rebind),
media: NewMedia(opts.DB, opts.Rebind, opts.IsUniqueViolation),
}, nil
}
@@ -109,6 +111,9 @@ func (s *Store) Sync() storage.SyncStore { return s.sync }
// Federation implements storage.Database.
func (s *Store) Federation() storage.FederationStore { return s.fed }
// Media implements storage.Database.
func (s *Store) Media() storage.MediaStore { return s.media }
// Migrate implements storage.Database.
func (s *Store) Migrate(ctx context.Context) error {
ms, err := s.migrations()
+4
View File
@@ -75,6 +75,10 @@ type Database interface {
// outbound queue and its backoff, and the inbound transaction log.
Federation() FederationStore
// Media is the mediaapi store: the metadata of uploaded and cached
// remote media, and of their thumbnails.
Media() MediaStore
// Close releases the connection pool.
Close() error
}