Files
OwnCord/Server/db/models.go
T
J3vbandClaude Opus 4.8 58005c9c6f feat(auth): revocable API tokens, introspect MCP server, and a Go 1.26 idiom pass (#1266)
* 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>
2026-07-29 13:25:46 +02:00

231 lines
6.7 KiB
Go

package db
import "time"
// User represents a row in the users table.
type User struct {
ID int64
Username string
PasswordHash string `json:"-"`
Avatar *string
RoleID int64
TOTPSecret *string `json:"-"`
Status string
CreatedAt string
LastSeen *string
Banned bool
BanReason *string
BanExpires *string
// IdentityPublicKey is the long-term E2EE identity public key (base64,
// ECDSA P-256) used for TOFU pinning of voice E2EE announces. Nil = not
// published (legacy client).
IdentityPublicKey *string
}
// Session represents a row in the sessions table.
type Session struct {
ID int64
UserID int64
TokenHash string `json:"-"`
Device string
IP string
CreatedAt string
LastUsed string
ExpiresAt string
}
// APIToken represents a row in the api_tokens table — a long-lived, revocable
// bearer token that authenticates as UserID with that user's role/permissions.
// Raw tokens are never stored; TokenHash is the SHA-256 hex, like Session.
type APIToken struct {
ID int64
UserID int64
TokenHash string `json:"-"`
Label string
CreatedAt string
LastUsed *string
ExpiresAt *string // nil = never expires
RevokedAt *string // nil = active
}
// APITokenListItem is one row of the admin/CLI token listing. It carries the
// owning user's name for display and deliberately omits the hash.
type APITokenListItem struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Label string `json:"label"`
CreatedAt string `json:"created_at"`
LastUsed *string `json:"last_used"`
ExpiresAt *string `json:"expires_at"`
RevokedAt *string `json:"revoked_at"`
}
// Invite represents a row in the invites table.
type Invite struct {
ID int64
Code string
CreatedBy int64
Uses int
MaxUses *int
ExpiresAt *string
Revoked bool
CreatedAt string
}
// Role represents a row in the roles table.
type Role struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color *string `json:"color"`
Permissions int64 `json:"permissions"`
Position int `json:"position"`
IsDefault bool `json:"is_default"`
}
// Channel represents a row in the channels table.
type Channel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Topic string `json:"topic"`
Position int `json:"position"`
SlowMode int `json:"slow_mode"`
Archived bool `json:"archived"`
CreatedAt string `json:"created_at"`
VoiceMaxUsers int `json:"voice_max_users"`
VoiceQuality *string `json:"voice_quality,omitempty"`
MixingThreshold *int `json:"mixing_threshold,omitempty"`
VoiceMaxVideo int `json:"voice_max_video"`
}
// Message represents a row in the messages table.
type Message struct {
ID int64
ChannelID int64
UserID int64
Content string
ReplyTo *int64
EditedAt *string
Deleted bool
Pinned bool
Timestamp string
}
// MessageWithUser joins a Message with the author's public fields.
type MessageWithUser struct {
Message
Username string
Avatar *string
}
// ReactionCount is an aggregated reaction count for a single emoji.
type ReactionCount struct {
Emoji string
Count int
MeReacted bool
}
// MessageSearchResult is a row returned by the FTS5 message search.
type MessageSearchResult struct {
MessageID int64 `json:"message_id"`
ChannelID int64 `json:"channel_id"`
ChannelName string `json:"channel_name"`
User UserPublic `json:"user"`
Content string `json:"content"`
Timestamp string `json:"timestamp"`
}
// UserPublic is the public-facing user shape for API responses.
type UserPublic struct {
ID int64 `json:"id"`
Username string `json:"username"`
Avatar *string `json:"avatar,omitempty"`
}
// MessageAPIResponse matches the API.md shape for GET /channels/{id}/messages.
type MessageAPIResponse struct {
ID int64 `json:"id"`
ChannelID int64 `json:"channel_id"`
User UserPublic `json:"user"`
Content string `json:"content"`
ReplyTo *int64 `json:"reply_to"`
Attachments []AttachmentInfo `json:"attachments"`
Reactions []ReactionInfo `json:"reactions"`
Pinned bool `json:"pinned"`
EditedAt *string `json:"edited_at"`
Deleted bool `json:"deleted"`
Timestamp string `json:"timestamp"`
}
// AttachmentInfo is the attachment shape in API responses.
type AttachmentInfo struct {
ID string `json:"id"`
Filename string `json:"filename"`
Size int64 `json:"size"`
Mime string `json:"mime"`
URL string `json:"url"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
}
// ReactionInfo is the reaction shape in API responses.
type ReactionInfo struct {
Emoji string `json:"emoji"`
Count int `json:"count"`
Me bool `json:"me"`
}
// VoiceState represents a row in the voice_states table.
// It tracks which voice channel a user is in and their current audio state.
type VoiceState struct {
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
Username string `json:"username"`
Muted bool `json:"muted"`
Deafened bool `json:"deafened"`
Speaking bool `json:"speaking"`
Camera bool `json:"camera"`
Screenshare bool `json:"screenshare"`
JoinedAt string `json:"-"`
}
// ChannelUnread holds per-user unread data for a single channel.
type ChannelUnread struct {
LastMessageID int64 `json:"last_message_id"`
UnreadCount int `json:"unread_count"`
}
// ServerStats contains aggregate counts for the admin dashboard.
type ServerStats struct {
UserCount int64 `json:"user_count"`
MessageCount int64 `json:"message_count"`
ChannelCount int64 `json:"channel_count"`
InviteCount int64 `json:"invite_count"`
DBSizeBytes int64 `json:"db_size_bytes"`
OnlineCount int `json:"online_count"`
}
// UserWithRole extends User with the name of the user's role.
type UserWithRole struct {
User
RoleName string `json:"role_name"`
}
// AuditEntry represents a single row from the audit_log table joined with the
// actor's username.
type AuditEntry struct {
ID int64 `json:"id"`
ActorID int64 `json:"actor_id"`
ActorName string `json:"actor_name"`
Action string `json:"action"`
TargetType string `json:"target_type"`
TargetID int64 `json:"target_id"`
Detail string `json:"detail"`
CreatedAt string `json:"created_at"`
}
// sessionTTL is the duration a session remains valid after creation.
const sessionTTL = 30 * 24 * time.Hour