Files
Warp/server/internal/storage/pocketbase/admin.go
T
tajniak81andClaude Opus 5.5 a47e15cc1c
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
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
CI / Desktop client (ubuntu-24.04) (push) Canceled after 0s
CI / Desktop client (windows-latest) (push) Canceled after 0s
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

343 lines
10 KiB
Go

package pocketbase
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Admin is the PocketBase implementation of storage.AdminStore.
type Admin struct{ *base }
// registrationTokenColumns reads uses_allowed as NULL for a token without a
// limit, as the SQL drivers store it.
const registrationTokenColumns = `token, CASE WHEN has_uses_allowed THEN uses_allowed END,
completed, expires_at_ms, created_at_ms`
// CreateRegistrationToken implements storage.AdminStore.
func (s *Admin) CreateRegistrationToken(ctx context.Context, rt storage.RegistrationToken) error {
err := s.write(ctx, func(t *tx) error {
taken, err := t.exists("admin_registration_tokens", `token = ?`, rt.Token)
if err != nil {
return err
}
if taken {
return storage.ErrRegistrationTokenExists
}
row := rec{
"token": rt.Token, "completed": rt.Completed,
"expires_at_ms": rt.ExpiresAtMS, "created_at_ms": rt.CreatedAtMS,
}
setUsesAllowed(row, rt.UsesAllowed)
t.put("admin_registration_tokens", row)
return nil
})
switch {
case errors.Is(err, storage.ErrRegistrationTokenExists):
return err
case err != nil:
return fmt.Errorf("create a registration token: %w", err)
}
return nil
}
// setUsesAllowed writes an optional use limit into a row.
func setUsesAllowed(row rec, n *int) {
if n == nil {
row["uses_allowed"], row["has_uses_allowed"] = int64(0), false
return
}
row["uses_allowed"], row["has_uses_allowed"] = int64(*n), true
}
// RegistrationToken implements storage.AdminStore.
func (s *Admin) RegistrationToken(ctx context.Context, token string) (storage.RegistrationToken, error) {
rt, err := scanRegistrationToken(s.db.QueryRowContext(ctx, `SELECT `+registrationTokenColumns+`
FROM admin_registration_tokens WHERE token = ?`, token).Scan)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.RegistrationToken{}, storage.ErrNoSuchRegistrationToken
case err != nil:
return storage.RegistrationToken{}, fmt.Errorf("read a registration token: %w", err)
}
return rt, nil
}
// RegistrationTokens implements storage.AdminStore.
func (s *Admin) RegistrationTokens(ctx context.Context) ([]storage.RegistrationToken, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+registrationTokenColumns+`
FROM admin_registration_tokens ORDER BY token`)
if err != nil {
return nil, fmt.Errorf("list registration tokens: %w", err)
}
defer func() { _ = rows.Close() }()
out := []storage.RegistrationToken{}
for rows.Next() {
rt, err := scanRegistrationToken(rows.Scan)
if err != nil {
return nil, fmt.Errorf("list registration tokens: %w", err)
}
out = append(out, rt)
}
return out, rows.Err()
}
// UpdateRegistrationToken implements storage.AdminStore.
func (s *Admin) UpdateRegistrationToken(ctx context.Context, token string, usesAllowed *int, expiresAtMS int64) error {
err := s.write(ctx, func(t *tx) error {
row := rec{"expires_at_ms": expiresAtMS}
setUsesAllowed(row, usesAllowed)
n, err := t.setWhere("admin_registration_tokens", row, `token = ?`, token)
if err != nil {
return fmt.Errorf("update a registration token: %w", err)
}
if n == 0 {
return storage.ErrNoSuchRegistrationToken
}
return nil
})
return err
}
// DeleteRegistrationToken implements storage.AdminStore.
func (s *Admin) DeleteRegistrationToken(ctx context.Context, token string) error {
return s.write(ctx, func(t *tx) error {
found, err := t.delKey("admin_registration_tokens", token)
if err != nil {
return fmt.Errorf("delete a registration token: %w", err)
}
if !found {
return storage.ErrNoSuchRegistrationToken
}
return nil
})
}
// UseRegistrationToken implements storage.AdminStore. The check and the
// spend happen under the write lock, so two registrations cannot both take
// the last use.
func (s *Admin) UseRegistrationToken(ctx context.Context, token string, nowMS int64) error {
return s.write(ctx, func(t *tx) error {
var completed int64
err := t.queryRow(`SELECT completed FROM admin_registration_tokens
WHERE token = ?
AND (NOT has_uses_allowed OR completed < uses_allowed)
AND (expires_at_ms = 0 OR expires_at_ms > ?)`, token, nowMS).Scan(&completed)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.ErrNoSuchRegistrationToken
case err != nil:
return fmt.Errorf("spend a registration token: %w", err)
}
t.put("admin_registration_tokens", rec{"token": token, "completed": completed + 1})
return nil
})
}
// ReleaseRegistrationToken implements storage.AdminStore. A token deleted in
// the meantime has nothing to give back to, which is not an error.
func (s *Admin) ReleaseRegistrationToken(ctx context.Context, token string) error {
err := s.write(ctx, func(t *tx) error {
var completed int64
err := t.queryRow(`SELECT completed FROM admin_registration_tokens WHERE token = ? AND completed > 0`, token).
Scan(&completed)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil
case err != nil:
return err
}
t.put("admin_registration_tokens", rec{"token": token, "completed": completed - 1})
return nil
})
if err != nil {
return fmt.Errorf("release a registration token: %w", err)
}
return nil
}
func scanRegistrationToken(scan func(...any) error) (storage.RegistrationToken, error) {
var (
rt storage.RegistrationToken
uses sql.NullInt64
)
if err := scan(&rt.Token, &uses, &rt.Completed, &rt.ExpiresAtMS, &rt.CreatedAtMS); err != nil {
return storage.RegistrationToken{}, err
}
if uses.Valid {
n := int(uses.Int64)
rt.UsesAllowed = &n
}
return rt, nil
}
const (
auditColumns = `audit_id, ts_ms, actor, action, target, details_json`
defaultAuditPage = 50
maxAuditPage = 1000
)
// AppendAudit implements storage.AdminStore.
func (s *Admin) AppendAudit(ctx context.Context, e storage.AuditEntry) (int64, error) {
details := string(e.Details)
if details == "" {
details = "{}"
}
var id int64
err := s.write(ctx, func(t *tx) error {
var err error
if id, err = t.next(adminSequences, seqAudit); err != nil {
return err
}
t.put("admin_audit_log", rec{
"audit_id": id, "ts_ms": e.TSMS, "actor": e.Actor,
"action": e.Action, "target": e.Target, "details_json": details,
})
return nil
})
if err != nil {
return 0, fmt.Errorf("append to the audit log: %w", err)
}
return id, nil
}
// AuditLog implements storage.AdminStore.
func (s *Admin) AuditLog(ctx context.Context, f storage.AuditFilter) ([]storage.AuditEntry, error) {
var (
conditions []string
args []any
)
if f.Before > 0 {
conditions = append(conditions, `audit_id < ?`)
args = append(args, f.Before)
}
if f.Actor != "" {
conditions = append(conditions, `actor = ?`)
args = append(args, f.Actor)
}
if f.Action != "" {
conditions = append(conditions, `action = ?`)
args = append(args, f.Action)
}
limit := f.Limit
switch {
case limit <= 0:
limit = defaultAuditPage
case limit > maxAuditPage:
limit = maxAuditPage
}
args = append(args, limit)
query := `SELECT ` + auditColumns + ` FROM admin_audit_log`
if len(conditions) > 0 {
query += ` WHERE ` + strings.Join(conditions, " AND ")
}
query += ` ORDER BY audit_id DESC LIMIT ?`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("read the audit log: %w", err)
}
defer func() { _ = rows.Close() }()
out := []storage.AuditEntry{}
for rows.Next() {
var (
e storage.AuditEntry
details string
)
if err := rows.Scan(&e.ID, &e.TSMS, &e.Actor, &e.Action, &e.Target, &details); err != nil {
return nil, fmt.Errorf("read the audit log: %w", err)
}
e.Details = []byte(details)
out = append(out, e)
}
return out, rows.Err()
}
// ServerNoticeRoom implements storage.AdminStore.
func (s *Admin) ServerNoticeRoom(ctx context.Context, localpart string) (string, error) {
var roomID string
err := s.db.QueryRowContext(ctx, `SELECT room_id FROM admin_server_notice_rooms WHERE localpart = ?`, localpart).
Scan(&roomID)
switch {
case errors.Is(err, sql.ErrNoRows):
return "", storage.ErrNoServerNoticeRoom
case err != nil:
return "", fmt.Errorf("read the server notice room of %q: %w", localpart, err)
}
return roomID, nil
}
// SetServerNoticeRoom implements storage.AdminStore.
func (s *Admin) SetServerNoticeRoom(ctx context.Context, localpart, roomID string) error {
err := s.write(ctx, func(t *tx) error {
t.put("admin_server_notice_rooms", rec{"localpart": localpart, "room_id": roomID})
return nil
})
if err != nil {
return fmt.Errorf("record the server notice room of %q: %w", localpart, err)
}
return nil
}
// AppServices is the PocketBase implementation of storage.AppServiceStore.
type AppServices struct{ *base }
// AppServiceState implements storage.AppServiceStore.
func (s *AppServices) AppServiceState(ctx context.Context, id string, startPos int64) (storage.AppServiceState, error) {
var st storage.AppServiceState
err := s.write(ctx, func(t *tx) error {
err := t.queryRow(`SELECT as_id, stream_pos, txn_id, txn_to FROM appservice_state WHERE as_id = ?`, id).
Scan(&st.ID, &st.StreamPos, &st.TxnID, &st.TxnTo)
switch {
case errors.Is(err, sql.ErrNoRows):
st = storage.AppServiceState{ID: id, StreamPos: startPos}
t.put("appservice_state", rec{"as_id": id, "stream_pos": startPos, "txn_id": int64(0), "txn_to": int64(0)})
return nil
case err != nil:
return err
}
return nil
})
if err != nil {
return storage.AppServiceState{}, fmt.Errorf("read the cursor of application service %q: %w", id, err)
}
return st, nil
}
// BeginTransaction implements storage.AppServiceStore.
func (s *AppServices) BeginTransaction(ctx context.Context, id string, txnID, to int64) error {
err := s.write(ctx, func(t *tx) error {
_, err := t.setWhere("appservice_state", rec{"txn_id": txnID, "txn_to": to}, `as_id = ?`, id)
return err
})
if err != nil {
return fmt.Errorf("begin transaction %d of application service %q: %w", txnID, id, err)
}
return nil
}
// CompleteTransaction implements storage.AppServiceStore.
func (s *AppServices) CompleteTransaction(ctx context.Context, id string, to int64) error {
err := s.write(ctx, func(t *tx) error {
_, err := t.setWhere("appservice_state", rec{"stream_pos": to, "txn_to": int64(0)}, `as_id = ?`, id)
return err
})
if err != nil {
return fmt.Errorf("advance the cursor of application service %q: %w", id, err)
}
return nil
}
var (
_ storage.AdminStore = (*Admin)(nil)
_ storage.AppServiceStore = (*AppServices)(nil)
)