Files
Warp/server/internal/storage/pocketbase/media.go
T
tajniak81andClaude Opus 5.5 a47e15cc1c
CI / Test (ubuntu-latest) (push) Waiting to run
CI / Test (windows-latest) (push) Waiting to run
CI / Storage tests on PostgreSQL (push) Waiting to run
CI / Complement (federation off) (push) Waiting to run
CI / golangci-lint (push) Waiting to run
CI / Smoke test the binary (push) Waiting to run
CI / Admin panel (push) Waiting to run
CI / Web client (push) Waiting to run
CI / Desktop client (ubuntu-24.04) (push) Waiting to run
CI / Desktop client (windows-latest) (push) Waiting to run
PocketBase as a standalone database server
The pocketbase driver no longer embeds PocketBase: Warp keeps its data as
PocketBase collections on a server of its own and reaches it only through
the REST API, the way the postgres driver reaches PostgreSQL.
database.connection_string is the server's URL, and Warp signs in as the
superuser in WARP_PB_SUPERUSER_EMAIL / WARP_PB_SUPERUSER_PASSWORD (or a
WARP_PB_SUPERUSER_FILE of the two).

Every SQL table is a superuser-only collection of the same name and
columns, created by warp migrate. Reads go through PocketBase's SQL
endpoint with the SQL drivers' queries, via a read-only database/sql
driver that inlines arguments, moves long ones into warp_params and pages
past 1000 rows. Writes are record operations in batch requests under one
write lock in the Warp process. Media is a protected file field in
warp_media, local or in the S3 bucket Warp writes into PocketBase's
settings. Search matches word prefixes with LIKE. The storage tests run
against an in-process PocketBase and pass as on SQLite.

Docker: the PocketBase stacks get a pocketbase service running
tajniak81/pocketbase-docker-root (WARP_PB_IMAGE), with a pb-data volume
and its admin UI on loopback; the AIO images run that PocketBase binary
as a process beside the homeserver, making up a superuser when none is
set. The .env examples, READMEs, docs and warp.yaml.example follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:31:08 +01:00

196 lines
6.7 KiB
Go

package pocketbase
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Media is the PocketBase implementation of storage.MediaStore: the
// metadata of media. The content is the blob store's.
type Media struct{ *base }
// CreateMedia implements storage.MediaStore.
func (s *Media) CreateMedia(ctx context.Context, m storage.Media) error {
return s.write(ctx, func(t *tx) error {
taken, err := t.exists("media_repository", `media_origin = ? AND media_id = ?`, m.Origin, m.MediaID)
if err != nil {
return fmt.Errorf("store media %s/%s: %w", m.Origin, m.MediaID, err)
}
if taken {
return storage.ErrMediaExists
}
t.put("media_repository", mediaRow(m))
return nil
})
}
func mediaRow(m storage.Media) rec {
return rec{
"media_origin": m.Origin, "media_id": m.MediaID, "content_type": m.ContentType,
"upload_name": m.UploadName, "file_size": m.Size, "sha256": m.SHA256,
"user_id": m.UserID, "created_at_ms": m.CreatedAtMS, "pending": m.Pending,
"expires_at_ms": m.ExpiresAtMS,
}
}
// 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, `
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 = ?`, 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)
}
}
// CompleteUpload implements storage.MediaStore. Only pending media is
// completed, so that two uploads for the same ID cannot both win.
func (s *Media) CompleteUpload(ctx context.Context, origin, mediaID string, c storage.MediaContent) error {
var completed bool
err := s.write(ctx, func(t *tx) error {
n, err := t.setWhere("media_repository", rec{
"content_type": c.ContentType, "upload_name": c.UploadName, "file_size": c.Size,
"sha256": c.SHA256, "pending": false, "expires_at_ms": int64(0),
}, `media_origin = ? AND media_id = ? AND pending = ?`, origin, mediaID, true)
completed = n == 1
return err
})
if err != nil {
return fmt.Errorf("complete upload %s/%s: %w", origin, mediaID, err)
}
if completed {
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
}
// 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, `SELECT COUNT(*) FROM media_repository
WHERE pending = ? AND user_id = ? AND expires_at_ms > ?`, true, userID, nowMS).Scan(&n); err != nil {
return 0, fmt.Errorf("count the pending uploads of %s: %w", userID, err)
}
return n, nil
}
// DeleteExpiredUploads implements storage.MediaStore.
func (s *Media) DeleteExpiredUploads(ctx context.Context, nowMS int64) (int, error) {
var n int
err := s.write(ctx, func(t *tx) error {
var err error
n, err = t.delWhere("media_repository", `pending = ? AND expires_at_ms <= ?`, true, nowMS)
return err
})
if err != nil {
return 0, fmt.Errorf("delete expired uploads: %w", err)
}
return n, nil
}
// 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, `
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 = ?`,
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)
}
}
// StoreThumbnail implements storage.MediaStore.
func (s *Media) StoreThumbnail(ctx context.Context, t storage.Thumbnail) error {
err := s.write(ctx, func(w *tx) error {
w.put("media_thumbnails", rec{
"media_origin": t.Origin, "media_id": t.MediaID, "width": t.Width, "height": t.Height,
"method": t.Method, "content_type": t.ContentType, "file_size": t.Size,
"sha256": t.SHA256, "created_at_ms": t.CreatedAtMS,
})
return nil
})
if err != nil {
return fmt.Errorf("store a thumbnail of %s/%s: %w", t.Origin, t.MediaID, err)
}
return nil
}
// MediaUsage implements storage.MediaStore. It counts every upload at its
// own size, even when two of them are the same bytes.
func (s *Media) MediaUsage(ctx context.Context, origin, userID string) (storage.MediaUsage, error) {
var u storage.MediaUsage
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM media_repository
WHERE user_id = ? AND media_origin = ? AND pending = ?`, userID, origin, false).
Scan(&u.Count, &u.Bytes); err != nil {
return storage.MediaUsage{}, fmt.Errorf("total the uploads of %s: %w", userID, err)
}
return u, nil
}
// MediaQuota implements storage.MediaStore.
func (s *Media) MediaQuota(ctx context.Context, userID string) (int64, bool, error) {
var maxBytes int64
err := s.db.QueryRowContext(ctx, `SELECT max_bytes FROM media_quotas WHERE user_id = ?`, userID).Scan(&maxBytes)
switch {
case errors.Is(err, sql.ErrNoRows):
return 0, false, nil
case err != nil:
return 0, false, fmt.Errorf("read the media quota of %s: %w", userID, err)
}
return maxBytes, true, nil
}
// SetMediaQuota implements storage.MediaStore.
func (s *Media) SetMediaQuota(ctx context.Context, userID string, maxBytes int64) error {
err := s.write(ctx, func(t *tx) error {
t.put("media_quotas", rec{"user_id": userID, "max_bytes": maxBytes})
return nil
})
if err != nil {
return fmt.Errorf("set the media quota of %s: %w", userID, err)
}
return nil
}
// DeleteMediaQuota implements storage.MediaStore.
func (s *Media) DeleteMediaQuota(ctx context.Context, userID string) error {
err := s.write(ctx, func(t *tx) error {
_, err := t.delKey("media_quotas", userID)
return err
})
if err != nil {
return fmt.Errorf("delete the media quota of %s: %w", userID, err)
}
return nil
}
var _ storage.MediaStore = (*Media)(nil)