package main import ( "context" "crypto/rand" "database/sql" "encoding/json" "errors" "fmt" "math/big" "os" "path/filepath" "regexp" "time" "unicode/utf8" _ "github.com/mattn/go-sqlite3" ) const ( maximumCodeBytes = 200 * 1024 maximumLoadoutsBytes = 512 * 1024 maximumTitleRunes = 80 ) var playgroundIDPattern = regexp.MustCompile(`^[a-z0-9]{6,24}$`) type playground struct { ID string `json:"id"` Revision int `json:"revision"` Title string `json:"title"` Code string `json:"code"` Loadouts json.RawMessage `json:"loadouts"` CreatedAt time.Time `json:"createdAt"` } type playgroundStore struct { *sql.DB } func openPlaygroundStore(path string) (*playgroundStore, error) { if path != ":memory:" { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } } database, err := sql.Open("sqlite3", path+"?_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL") if err != nil { return nil, err } database.SetMaxOpenConns(1) store := &playgroundStore{DB: database} if err := store.initialize(); err != nil { database.Close() return nil, err } return store, nil } func (store *playgroundStore) initialize() error { _, err := store.Exec(` CREATE TABLE IF NOT EXISTS playgrounds ( id TEXT NOT NULL, revision INTEGER NOT NULL CHECK (revision > 0), title TEXT NOT NULL, code TEXT NOT NULL, loadouts TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, PRIMARY KEY (id, revision) ); CREATE INDEX IF NOT EXISTS playgrounds_created_at ON playgrounds (created_at DESC); `) if err != nil { return err } rows, err := store.Query(`PRAGMA table_info(playgrounds)`) if err != nil { return err } hasLoadouts := false for rows.Next() { var position, notNull, primaryKey int var name, kind string var defaultValue any if err := rows.Scan(&position, &name, &kind, ¬Null, &defaultValue, &primaryKey); err != nil { rows.Close() return err } hasLoadouts = hasLoadouts || name == "loadouts" } if err := rows.Err(); err != nil { rows.Close() return err } if err := rows.Close(); err != nil { return err } if !hasLoadouts { _, err = store.Exec(`ALTER TABLE playgrounds ADD COLUMN loadouts TEXT NOT NULL DEFAULT '[]'`) } return err } func (store *playgroundStore) save(ctx context.Context, id, title, code, loadouts string) (playground, error) { if id != "" && !playgroundIDPattern.MatchString(id) { return playground{}, errors.New("invalid playground id") } if title == "" || utf8.RuneCountInString(title) > maximumTitleRunes { return playground{}, fmt.Errorf("title must contain 1 to %d characters", maximumTitleRunes) } if code == "" || len(code) > maximumCodeBytes || !utf8.ValidString(code) { return playground{}, fmt.Errorf("code must contain 1 to %d bytes of UTF-8", maximumCodeBytes) } if loadouts == "" { loadouts = "[]" } if len(loadouts) > maximumLoadoutsBytes || !utf8.ValidString(loadouts) { return playground{}, fmt.Errorf("loadouts must contain at most %d bytes of UTF-8", maximumLoadoutsBytes) } var loadoutList []json.RawMessage if err := json.Unmarshal([]byte(loadouts), &loadoutList); err != nil || loadoutList == nil { return playground{}, errors.New("loadouts must be a JSON array") } if id == "" { var err error id, err = randomPlaygroundID() if err != nil { return playground{}, err } } transaction, err := store.BeginTx(ctx, nil) if err != nil { return playground{}, err } defer transaction.Rollback() var revision int if err := transaction.QueryRowContext( ctx, `SELECT COALESCE(MAX(revision), 0) + 1 FROM playgrounds WHERE id = ?`, id, ).Scan(&revision); err != nil { return playground{}, err } createdAt := time.Now().UTC().Truncate(time.Millisecond) if _, err := transaction.ExecContext( ctx, `INSERT INTO playgrounds (id, revision, title, code, loadouts, created_at) VALUES (?, ?, ?, ?, ?, ?)`, id, revision, title, code, loadouts, createdAt.Format(time.RFC3339Nano), ); err != nil { return playground{}, err } if err := transaction.Commit(); err != nil { return playground{}, err } return playground{ ID: id, Revision: revision, Title: title, Code: code, Loadouts: json.RawMessage(loadouts), CreatedAt: createdAt, }, nil } func (store *playgroundStore) get(ctx context.Context, id string, revision int) (playground, error) { if !playgroundIDPattern.MatchString(id) || revision < 1 { return playground{}, sql.ErrNoRows } var result playground var createdAt, loadouts string err := store.QueryRowContext( ctx, `SELECT id, revision, title, code, loadouts, created_at FROM playgrounds WHERE id = ? AND revision = ?`, id, revision, ).Scan(&result.ID, &result.Revision, &result.Title, &result.Code, &loadouts, &createdAt) if err != nil { return playground{}, err } result.Loadouts = json.RawMessage(loadouts) result.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt) return result, err } func randomPlaygroundID() (string, error) { const alphabet = "abcdefghijkmnopqrstuvwxyz23456789" result := make([]byte, 10) for index := range result { value, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) if err != nil { return "", err } result[index] = alphabet[value.Int64()] } return string(result), nil }