mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(auth): add revocable API tokens (bot/service auth) Add long-lived, revocable API tokens so headless clients (the introspection MCP tool, bots, CI) can authenticate without a password. Presented as "Authorization: Bearer <token>", a token authenticates as a specific user, inheriting that user's role and permissions. - migration 018 + dedicated api_tokens table (kept separate from sessions so bulk logout and the per-user session cap never touch these); only the SHA-256 hash is stored, raw token shown once at creation - auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware and adminAuthMiddleware now call. Sessions are matched first so existing login behavior is unchanged; API tokens are a fallback only on session miss. A DB outage is returned wrapped, never mistaken for a bad token. - `server token create|list|revoke` CLI: mints directly against the DB with no HTTP and no login — the password-free bootstrap path - tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6), api middleware integration (valid + revoked token) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tools): add owncord-introspect MCP server A local MCP dev tool that lets Claude Code introspect a running OwnCord instance: read its logs, query any REST endpoint, and tail the desktop client's log file. It is a thin wrapper over the existing API plus the client log — no new product surface. - tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk) exposes api_request (full read-write passthrough), server_logs (admin SSE ring-buffer stream), client_logs (reads the desktop log file) - authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed cert and skips hostname checks (the cert has no SAN) - registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN}) - un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while tools/livekit-server.exe and node_modules stay ignored - docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dependencies): update and add various crate versions in Cargo.lock * feat(admin): manage API tokens from the admin panel Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke API tokens from the web admin panel. Previously only the `server token` CLI could manage them, which requires shell access to the host. - POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like backups/updates): an HTTP token-mint endpoint is a network-reachable credential-minting surface, and API tokens deliberately survive password change + bulk logout, so a hijacked admin session must not mint one. - Reuses the same db.*APIToken calls as the CLI; create sources the actor from request context (audits who clicked, not the bound user); the raw token is returned once in the 201 body, never stored. - Add json tags to db.APITokenListItem for snake_case wire consistency. - Admin panel: "API Tokens" nav item + create modal, show-once reveal, revoke confirm in admin/static/index.html. - Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: modernize to Go 1.26 idioms + enable modernize linter Apply `golangci-lint modernize` autofixes across the server and enable the linter in .golangci.yml so these stop re-accumulating (they built up only because modernize was never in the config). Production code: slices.Contains for hand-rolled membership loops (api router, ws origin, db/account, plugin manifest); strings.SplitSeq for allocation-free line/segment iteration (db/migrate, updater, livekit_proxy); strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner); any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go, slices.Sort, maps.Copy, new(expr), interface{}->any. - plugin/manifest.go parent-traversal check applied by hand: modernize skipped it (two conflicting rewrites); used the slices.Contains form. - Removed the now-dead ptr() test helper after newexpr inlined its callers. - Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite. No behavior change. All four tag variants build, full test suite is green, and golangci-lint (with modernize enabled) reports 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
327 lines
9.6 KiB
Go
327 lines
9.6 KiB
Go
package db
|
|
|
|
// migrate.go — tracked migration runner for the OwnCord server.
|
|
//
|
|
// Each .sql file in the provided FS is applied exactly once. The
|
|
// schema_versions table records every applied migration filename and the UTC
|
|
// timestamp at which it was applied.
|
|
//
|
|
// Seeding for existing databases
|
|
// --------------------------------
|
|
// When the server is first upgraded to include migration tracking, existing
|
|
// databases will have all schema tables in place but no schema_versions table.
|
|
// Without seeding, every migration would re-run and could destroy data.
|
|
//
|
|
// The seeding heuristic: if schema_versions does not exist AND the "users"
|
|
// table already exists, we assume all migrations in the current FS have
|
|
// already been applied. We create schema_versions and insert every migration
|
|
// filename without executing the SQL, so subsequent runs treat them as done.
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"io/fs"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const createSchemaVersions = `
|
|
CREATE TABLE IF NOT EXISTS schema_versions (
|
|
version TEXT PRIMARY KEY,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`
|
|
|
|
// ensureSchemaVersions creates the tracking table if it does not yet exist.
|
|
func ensureSchemaVersions(d *DB) error {
|
|
if _, err := d.sqlDB.Exec(createSchemaVersions); err != nil {
|
|
return fmt.Errorf("creating schema_versions: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isExistingDatabase reports whether the database was previously migrated
|
|
// without tracking — detected by the presence of the "users" table.
|
|
func isExistingDatabase(d *DB) (bool, error) {
|
|
var name string
|
|
err := d.sqlDB.QueryRow(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'",
|
|
).Scan(&name)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("isExistingDatabase: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// schemaVersionsExists reports whether the schema_versions table is present.
|
|
func schemaVersionsExists(d *DB) (bool, error) {
|
|
var name string
|
|
err := d.sqlDB.QueryRow(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_versions'",
|
|
).Scan(&name)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("schemaVersionsExists: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// isApplied reports whether a migration filename has already been recorded.
|
|
func isApplied(d *DB, filename string) (bool, error) {
|
|
var v string
|
|
err := d.sqlDB.QueryRow(
|
|
"SELECT version FROM schema_versions WHERE version = ?", filename,
|
|
).Scan(&v)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("isApplied: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// sqlFilenames returns all .sql entries from the FS sorted lexicographically.
|
|
func sqlFilenames(fsys fs.FS) ([]string, error) {
|
|
entries, err := fs.ReadDir(fsys, ".")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading migrations dir: %w", err)
|
|
}
|
|
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return entries[i].Name() < entries[j].Name()
|
|
})
|
|
|
|
names := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
|
names = append(names, e.Name())
|
|
}
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// seedExistingDatabase inserts all migration filenames into schema_versions
|
|
// without executing them. This is called once when upgrading a pre-tracking
|
|
// database.
|
|
func seedExistingDatabase(d *DB, filenames []string) error {
|
|
tx, err := d.sqlDB.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin seed tx: %w", err)
|
|
}
|
|
for _, name := range filenames {
|
|
if _, execErr := tx.Exec(
|
|
"INSERT INTO schema_versions (version) VALUES (?)", name,
|
|
); execErr != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("seeding %s: %w", name, execErr)
|
|
}
|
|
}
|
|
if commitErr := tx.Commit(); commitErr != nil {
|
|
return fmt.Errorf("commit seed tx: %w", commitErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MigrateFS runs tracked migrations from the provided FS.
|
|
//
|
|
// Behaviour:
|
|
// 1. Create schema_versions if absent.
|
|
// 2. If this is the first run with tracking on an existing database (users
|
|
// table exists but schema_versions was just created), seed all filenames
|
|
// so they are not re-executed.
|
|
// 3. For each .sql file in lexicographic order: skip if already recorded,
|
|
// otherwise execute the SQL and record the filename.
|
|
func MigrateFS(database *DB, fsys fs.FS) error {
|
|
// Determine tracking state before we create schema_versions.
|
|
svExists, err := schemaVersionsExists(database)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create the tracking table (idempotent).
|
|
if err := ensureSchemaVersions(database); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Collect filenames first — needed for both seeding and normal application.
|
|
filenames, err := sqlFilenames(fsys)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Seeding path: schema_versions did not exist AND users table does, which
|
|
// means this is an existing database being upgraded to tracked migrations.
|
|
if !svExists {
|
|
existing, checkErr := isExistingDatabase(database)
|
|
if checkErr != nil {
|
|
return checkErr
|
|
}
|
|
if existing {
|
|
return seedExistingDatabase(database, filenames)
|
|
}
|
|
}
|
|
|
|
// Normal path: apply any migration not yet recorded.
|
|
for _, name := range filenames {
|
|
applied, applyErr := isApplied(database, name)
|
|
if applyErr != nil {
|
|
return applyErr
|
|
}
|
|
if applied {
|
|
continue
|
|
}
|
|
|
|
raw, readErr := fs.ReadFile(fsys, name)
|
|
if readErr != nil {
|
|
return fmt.Errorf("reading migration %s: %w", name, readErr)
|
|
}
|
|
|
|
if err := applyMigration(database, name, string(raw)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// applyMigration executes a single migration and records it. If the
|
|
// migration contains multiple statements (e.g. several ALTER TABLE ADD
|
|
// COLUMN), each is executed individually so that "duplicate column" errors
|
|
// from a prior partial run can be skipped — the column already exists and
|
|
// the intent is satisfied.
|
|
func applyMigration(database *DB, name, rawSQL string) error {
|
|
stmts := splitStatements(rawSQL)
|
|
|
|
tx, txErr := database.sqlDB.Begin()
|
|
if txErr != nil {
|
|
return fmt.Errorf("begin tx for %s: %w", name, txErr)
|
|
}
|
|
|
|
for _, stmt := range stmts {
|
|
if _, execErr := tx.Exec(stmt); execErr != nil {
|
|
if isDuplicateColumn(execErr) {
|
|
continue // column already exists — skip
|
|
}
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("executing migration %s: %w", name, execErr)
|
|
}
|
|
}
|
|
|
|
// Record the migration inside the same transaction so the migration
|
|
// and its tracking record are atomic.
|
|
if _, execErr := tx.Exec(
|
|
"INSERT INTO schema_versions (version) VALUES (?)", name,
|
|
); execErr != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("recording migration %s: %w", name, execErr)
|
|
}
|
|
|
|
if commitErr := tx.Commit(); commitErr != nil {
|
|
return fmt.Errorf("commit migration %s: %w", name, commitErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// splitStatements splits raw SQL into individual statements on semicolons,
|
|
// correctly handling BEGIN...END blocks used by CREATE TRIGGER definitions.
|
|
// Empty/comment-only fragments are discarded.
|
|
func splitStatements(raw string) []string {
|
|
out := make([]string, 0)
|
|
var buf strings.Builder
|
|
depth := 0
|
|
|
|
for line := range strings.SplitSeq(raw, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
// Track BEGIN...END depth for trigger bodies.
|
|
upperTrimmed := strings.ToUpper(trimmed)
|
|
if depth > 0 && (upperTrimmed == "END;" || upperTrimmed == "END") {
|
|
depth--
|
|
buf.WriteString(line)
|
|
buf.WriteString("\n")
|
|
if depth == 0 {
|
|
// END; closes the trigger — flush the entire block as one statement.
|
|
s := strings.TrimSpace(buf.String())
|
|
// Strip trailing semicolons so the executor doesn't choke.
|
|
s = strings.TrimRight(s, ";")
|
|
s = strings.TrimSpace(s)
|
|
if s != "" && !isCommentOnly(s) {
|
|
out = append(out, s)
|
|
}
|
|
buf.Reset()
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Detect BEGIN that opens a trigger body. The keyword appears at
|
|
// the end of a CREATE TRIGGER line (e.g. "... BEGIN") or on its
|
|
// own line inside a trigger definition.
|
|
if strings.HasSuffix(upperTrimmed, " BEGIN") || upperTrimmed == "BEGIN" {
|
|
depth++
|
|
buf.WriteString(line)
|
|
buf.WriteString("\n")
|
|
continue
|
|
}
|
|
|
|
if depth > 0 {
|
|
// Inside a BEGIN...END block — accumulate without splitting.
|
|
buf.WriteString(line)
|
|
buf.WriteString("\n")
|
|
continue
|
|
}
|
|
|
|
// Outside any block — split on semicolons within this line.
|
|
buf.WriteString(line)
|
|
buf.WriteString("\n")
|
|
|
|
// Check whether the accumulated buffer contains a semicolon to split on.
|
|
// We split the full buffer content, not just the current line, because a
|
|
// statement may span multiple lines before its terminating semicolon.
|
|
content := buf.String()
|
|
if strings.Contains(content, ";") {
|
|
parts := strings.Split(content, ";")
|
|
// All parts except the last are complete statements.
|
|
for _, p := range parts[:len(parts)-1] {
|
|
s := strings.TrimSpace(p)
|
|
if s == "" || isCommentOnly(s) {
|
|
continue
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
// The last part is the remainder after the final semicolon.
|
|
buf.Reset()
|
|
buf.WriteString(parts[len(parts)-1])
|
|
}
|
|
}
|
|
|
|
// Flush any remaining content (statement without trailing semicolon).
|
|
s := strings.TrimSpace(buf.String())
|
|
if s != "" && !isCommentOnly(s) {
|
|
out = append(out, s)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// isCommentOnly returns true if every line is a SQL comment or blank.
|
|
func isCommentOnly(s string) bool {
|
|
for line := range strings.SplitSeq(s, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" && !strings.HasPrefix(line, "--") {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isDuplicateColumn reports whether a SQLite error indicates a duplicate
|
|
// column name from an ALTER TABLE ADD COLUMN statement.
|
|
func isDuplicateColumn(err error) bool {
|
|
return err != nil && strings.Contains(err.Error(), "duplicate column name")
|
|
}
|