mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
- client: update endpoint now sends {{target}}-{{arch}}-{{bundle_type}} so the
server-echoed platforms key matches the updater plugin's
{os}-{arch}-{installer} lookup (previously bare {{target}} produced a key
the plugin never matches, so no update was ever surfaced)
- client: TOFU cert pin is scoped to the OwnCord server host via
HostScopedVerifier; the GitHub installer download validates against web PKI
instead of failing the pinned-fingerprint check on every install
- client: check/install share one build_updater helper so the two paths cannot
diverge; tauri-plugin-updater minor-pinned per its configure_client guidance
- server: client-update endpoint serves target-specific artifacts (NSIS,
per-arch AppImage) and returns 204 for targets without a published updater
artifact (deb, darwin) instead of always serving the Windows NSIS installer
- release: server-update-manifest.json now binds both OS assets (legacy
top-level pair kept pointing at the Windows binary so deployed servers still
verify); VerifyReleaseManifest resolves the entry matching the downloaded
asset, fixing Linux server self-update
- release: ARM64 staging renames installer, tar.gz and .sig consistently so
signatures keep pairing and arch-less names cannot collide with x86_64 assets
- ci: run cargo test --lib (Rust #[cfg(test)] code was never compiled in CI);
merge the two ptt tests that raced on the global PTT_VKEY atomic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1131 lines
25 KiB
Markdown
1131 lines
25 KiB
Markdown
# REST API Reference
|
|
|
|
OwnCord server REST API reference. All endpoints use the base URL `https://{server}:{port}/api/v1`.
|
|
|
|
---
|
|
|
|
## Authentication
|
|
|
|
All authenticated endpoints require a session token delivered via the `Authorization: Bearer {token}` header. Tokens are obtained from `POST /api/v1/auth/login`, `POST /api/v1/auth/register`, or `POST /api/v1/auth/verify-totp` after a partial 2FA challenge.
|
|
|
|
### Session Lifecycle
|
|
|
|
- Sessions are created on login/register and stored with a SHA-256 hash of the raw token, the client IP, User-Agent, and an expiry timestamp.
|
|
- Each authenticated request updates the session's `last_active` timestamp.
|
|
- Banned users are rejected at the middleware level with `403 FORBIDDEN`.
|
|
|
|
### Middleware Stack (all routes)
|
|
|
|
1. **RequestID** -- assigns a unique `X-Request-Id` response header.
|
|
2. **Recoverer** -- catches panics and returns 500.
|
|
3. **Request Logger** -- structured logging of method, path, status, duration.
|
|
4. **SecurityHeadersWithTLS** -- (adds `Strict-Transport-Security` when TLS is on) sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Content-Security-Policy: default-src 'self'`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, `Cache-Control: no-store`.
|
|
5. **MaxBodySize** -- 1 MiB default for all routes except `/api/v1/uploads` (which has its own 100 MiB limit).
|
|
|
|
---
|
|
|
|
## Standard Error Response
|
|
|
|
All error responses use this JSON envelope:
|
|
|
|
```json
|
|
{
|
|
"error": "ERROR_CODE",
|
|
"message": "Human-readable detail"
|
|
}
|
|
```
|
|
|
|
### Error Codes
|
|
|
|
| Code | HTTP Status | When It Occurs |
|
|
| ---- | ----------- | -------------- |
|
|
| `UNAUTHORIZED` | 401 | Missing/invalid/expired session token |
|
|
| `INVALID_CREDENTIALS` | 401 | Login/register with bad username/password/invite (generic to prevent enumeration) |
|
|
| `FORBIDDEN` | 403 | Insufficient permissions, banned account, or admin IP restriction |
|
|
| `NOT_FOUND` | 404 | Resource (channel, message, user, invite, file, backup) not found |
|
|
| `RATE_LIMITED` | 429 | Too many requests; response includes `Retry-After` header (seconds) |
|
|
| `INVALID_INPUT` / `BAD_REQUEST` | 400 | Malformed body, missing required fields, invalid query params |
|
|
| `CONFLICT` | 409 | Duplicate username on register, or server already up-to-date on update |
|
|
| `TOO_LARGE` | 413 | File exceeds upload size limit |
|
|
| `SERVER_ERROR` / `INTERNAL` | 500 | Internal server error |
|
|
| `BAD_GATEWAY` | 502 | Upstream failure (GitHub API, LiveKit, GIF provider, asset download) |
|
|
| `GIF_DISABLED` | 503 | GIF proxy is not configured on this server (no `gif.api_key`) |
|
|
|
|
---
|
|
|
|
## Auth Endpoints
|
|
|
|
### POST /api/v1/auth/register
|
|
|
|
Create a new account using an invite code. The first user is created via `/admin/api/setup` instead.
|
|
|
|
**Auth:** None (public)
|
|
**Rate limit:** 3 requests/minute per IP
|
|
|
|
#### Request
|
|
|
|
```json
|
|
{
|
|
"username": "alex",
|
|
"password": "MyStr0ng!Pass",
|
|
"invite_code": "abc123def"
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Notes |
|
|
| ----- | ---- | -------- | ----- |
|
|
| `username` | string | Yes | HTML-stripped, trimmed. Must be non-empty. |
|
|
| `password` | string | Yes | Validated for strength (min length, complexity). |
|
|
| `invite_code` | string | Yes | Must be a valid, non-expired, non-revoked invite with remaining uses. |
|
|
|
|
#### Response 201 Created
|
|
|
|
```json
|
|
{
|
|
"token": "raw-session-token-64-chars",
|
|
"user": {
|
|
"id": 2,
|
|
"username": "alex",
|
|
"avatar": "",
|
|
"status": "offline",
|
|
"role_id": 4,
|
|
"totp_enabled": false,
|
|
"created_at": "2026-03-24T12:00:00Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Errors
|
|
|
|
| Status | Code | Cause |
|
|
| ------ | ---- | ----- |
|
|
| 400 | `INVALID_INPUT` | Missing username/password/invite_code, or weak password |
|
|
| 400 | `INVALID_CREDENTIALS` | Bad invite code, expired/revoked invite, or duplicate username |
|
|
| 403 | `FORBIDDEN` | Registration is closed or unavailable while server-wide 2FA is required |
|
|
| 429 | `RATE_LIMITED` | Exceeded 3 registrations/minute from this IP |
|
|
| 500 | `SERVER_ERROR` | Hashing failure, session creation failure, or DB error |
|
|
|
|
---
|
|
|
|
### POST /api/v1/auth/login
|
|
|
|
Authenticate with username and password.
|
|
|
|
**Auth:** None (public)
|
|
**Rate limit:** 60 requests/minute per IP. After 10 consecutive failures from the same IP, the IP is locked out for 15 minutes.
|
|
|
|
#### Request
|
|
|
|
```json
|
|
{
|
|
"username": "alex",
|
|
"password": "MyStr0ng!Pass"
|
|
}
|
|
```
|
|
|
|
#### Response 200 OK
|
|
|
|
If the account does not have TOTP enabled:
|
|
|
|
```json
|
|
{
|
|
"token": "raw-session-token-64-chars",
|
|
"requires_2fa": false,
|
|
"user": {
|
|
"id": 1,
|
|
"username": "alex",
|
|
"avatar": "uuid.png",
|
|
"status": "offline",
|
|
"role_id": 4,
|
|
"totp_enabled": false,
|
|
"created_at": "2026-03-24T12:00:00Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
If the account has TOTP enabled:
|
|
|
|
```json
|
|
{
|
|
"partial_token": "opaque-partial-token",
|
|
"requires_2fa": true
|
|
}
|
|
```
|
|
|
|
#### Errors
|
|
|
|
| Status | Code | Cause |
|
|
| ------ | ---- | ----- |
|
|
| 400 | `INVALID_INPUT` | Missing username or password |
|
|
| 401 | `UNAUTHORIZED` | Wrong username or password |
|
|
| 403 | `FORBIDDEN` | Account is banned/suspended |
|
|
| 429 | `RATE_LIMITED` | IP locked out after 10 consecutive failures (15 min cooldown) |
|
|
| 500 | `SERVER_ERROR` | Session creation failure |
|
|
|
|
---
|
|
|
|
### POST /api/v1/auth/verify-totp
|
|
|
|
Complete a TOTP login challenge started by `POST /api/v1/auth/login`.
|
|
|
|
**Auth:** Required with the `partial_token` from the login response
|
|
**Rate limit:** 10 requests/minute per IP, plus a 5-attempt budget per partial challenge
|
|
|
|
#### Request
|
|
|
|
```json
|
|
{
|
|
"code": "123456"
|
|
}
|
|
```
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
{
|
|
"token": "raw-session-token-64-chars",
|
|
"requires_2fa": false,
|
|
"user": {
|
|
"id": 1,
|
|
"username": "alex",
|
|
"avatar": "uuid.png",
|
|
"status": "offline",
|
|
"role_id": 4,
|
|
"totp_enabled": true,
|
|
"created_at": "2026-03-24T12:00:00Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Errors
|
|
|
|
| Status | Code | Cause |
|
|
| ------ | ---- | ----- |
|
|
| 400 | `INVALID_INPUT` | Malformed request body |
|
|
| 401 | `UNAUTHORIZED` | Missing/expired challenge, invalid TOTP code, or challenge consumed |
|
|
| 500 | `SERVER_ERROR` | Session creation failure |
|
|
|
|
---
|
|
|
|
### GET /api/v1/auth/me
|
|
|
|
Get the current authenticated user's profile.
|
|
|
|
**Auth:** Required (Bearer token)
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
{
|
|
"id": 1,
|
|
"username": "alex",
|
|
"avatar": "uuid.png",
|
|
"status": "online",
|
|
"role_id": 2,
|
|
"totp_enabled": true,
|
|
"created_at": "2026-03-24T12:00:00Z"
|
|
}
|
|
```
|
|
|
|
| Field | Type | Description |
|
|
| ----- | ---- | ----------- |
|
|
| `id` | int64 | User ID |
|
|
| `username` | string | Display name |
|
|
| `avatar` | string | Avatar filename (UUID) or empty string |
|
|
| `status` | string | One of: `online`, `idle`, `dnd`, `offline` |
|
|
| `role_id` | int64 | Numeric role ID (1=Owner, 2=Admin, 3=Moderator, 4=Member) |
|
|
| `totp_enabled` | bool | Whether the user has a confirmed TOTP secret |
|
|
| `created_at` | string | ISO 8601 timestamp |
|
|
|
|
---
|
|
|
|
### POST /api/v1/auth/logout
|
|
|
|
Invalidate the current session token.
|
|
|
|
**Auth:** Required (Bearer token)
|
|
|
|
#### Response 204 No Content
|
|
|
|
---
|
|
|
|
### DELETE /api/v1/auth/account
|
|
|
|
Permanently delete the authenticated user's account. Requires password confirmation.
|
|
|
|
**Auth:** Required (Bearer token)
|
|
**Rate limit:** 5 requests/minute per IP. After 3 failed password attempts, the endpoint locks out for 15 minutes per user.
|
|
|
|
#### Request
|
|
|
|
```json
|
|
{
|
|
"password": "MyStr0ng!Pass"
|
|
}
|
|
```
|
|
|
|
#### Response 204 No Content
|
|
|
|
Account deleted successfully. All sessions, messages (soft-deleted), and associated data are cleaned up.
|
|
|
|
#### Errors
|
|
|
|
| Status | Code | Cause |
|
|
| ------ | ---- | ----- |
|
|
| 400 | `INVALID_INPUT` | Missing or incorrect password |
|
|
| 403 | `FORBIDDEN` | Cannot delete the last admin account |
|
|
| 429 | `RATE_LIMITED` | Locked out after 3 failed password attempts (15 min cooldown) |
|
|
| 500 | `SERVER_ERROR` | Database error during deletion |
|
|
|
|
---
|
|
|
|
### POST /api/v1/users/me/totp/enable
|
|
|
|
Start TOTP enrollment for the authenticated user. The secret is not persisted until `/api/v1/users/me/totp/confirm` succeeds.
|
|
|
|
**Auth:** Required
|
|
**Rate limit:** 5 requests/minute per IP
|
|
|
|
#### Request
|
|
|
|
```json
|
|
{
|
|
"password": "MyStr0ng!Pass"
|
|
}
|
|
```
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
{
|
|
"qr_uri": "otpauth://totp/OwnCord:alex?...",
|
|
"backup_codes": []
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### POST /api/v1/users/me/totp/confirm
|
|
|
|
Confirm a pending TOTP enrollment.
|
|
|
|
**Auth:** Required
|
|
**Rate limit:** 5 requests/minute per IP
|
|
|
|
#### Request
|
|
|
|
```json
|
|
{
|
|
"password": "MyStr0ng!Pass",
|
|
"code": "123456"
|
|
}
|
|
```
|
|
|
|
#### Response 204 No Content
|
|
|
|
---
|
|
|
|
### DELETE /api/v1/users/me/totp
|
|
|
|
Disable TOTP for the authenticated user.
|
|
|
|
**Auth:** Required
|
|
**Rate limit:** 5 requests/minute per IP
|
|
|
|
#### Request
|
|
|
|
```json
|
|
{
|
|
"password": "MyStr0ng!Pass"
|
|
}
|
|
```
|
|
|
|
#### Response 204 No Content
|
|
|
|
---
|
|
|
|
## User Profile & Sessions
|
|
|
|
### PATCH /api/v1/users/me
|
|
|
|
Update the authenticated user's profile (username and/or avatar).
|
|
Broadcasts a `user_update` WebSocket message to all clients on success.
|
|
|
|
**Auth:** Required
|
|
**Rate limit:** 10 requests/minute
|
|
|
|
#### Request
|
|
|
|
```json
|
|
{
|
|
"username": "newname",
|
|
"avatar": "upload-uuid.png"
|
|
}
|
|
```
|
|
|
|
Both fields optional; `avatar` may be `null` to clear it.
|
|
|
|
#### Response 200 OK
|
|
|
|
Returns the updated user object (same shape as `GET /api/v1/auth/me`).
|
|
|
|
---
|
|
|
|
### PUT /api/v1/users/me/password
|
|
|
|
Change the authenticated user's password. Verifies the old password, enforces
|
|
password strength, and revokes all *other* sessions on success.
|
|
|
|
**Auth:** Required
|
|
**Rate limit:** 5 requests/minute, plus a failed-confirmation lockout on
|
|
repeated wrong old passwords
|
|
|
|
#### Request
|
|
|
|
```json
|
|
{
|
|
"old_password": "OldPass!1",
|
|
"new_password": "NewStr0ng!Pass"
|
|
}
|
|
```
|
|
|
|
#### Response 204 No Content
|
|
|
|
Password changed and other sessions revoked. If the password change committed
|
|
but revoking other sessions failed, the endpoint returns **200 OK** with a
|
|
warning body instead (the new password is in effect — do not retry with the
|
|
old one).
|
|
|
|
#### Errors
|
|
|
|
| Status | Code | Cause |
|
|
| ------ | ---- | ----- |
|
|
| 400 | `INVALID_INPUT` | Weak new password, or new password equals old |
|
|
| 403 | `FORBIDDEN` | Incorrect old password |
|
|
| 429 | `RATE_LIMITED` | Too many attempts / lockout |
|
|
|
|
---
|
|
|
|
### GET /api/v1/users/me/sessions
|
|
|
|
List the authenticated user's active sessions.
|
|
|
|
**Auth:** Required
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
{
|
|
"sessions": [
|
|
{
|
|
"id": 12,
|
|
"device": "Mozilla/5.0 ...",
|
|
"ip": "192.168.1.100",
|
|
"created_at": "2026-07-01T10:00:00Z",
|
|
"last_used": "2026-07-19T09:00:00Z",
|
|
"is_current": true
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### DELETE /api/v1/users/me/sessions/{id}
|
|
|
|
Revoke one of the authenticated user's sessions by ID.
|
|
|
|
**Auth:** Required
|
|
|
|
#### Response 204 No Content
|
|
|
|
---
|
|
|
|
## Channel Endpoints
|
|
|
|
### GET /api/v1/channels
|
|
|
|
List all channels the authenticated user has `READ_MESSAGES` permission for. DM channels are NOT included (use `GET /api/v1/dms` instead).
|
|
|
|
**Auth:** Required
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
[
|
|
{
|
|
"id": 1,
|
|
"name": "general",
|
|
"type": "text",
|
|
"topic": "Welcome to the server!",
|
|
"category": "Text Channels",
|
|
"position": 0,
|
|
"slow_mode": 0,
|
|
"archived": false
|
|
}
|
|
]
|
|
```
|
|
|
|
| Field | Type | Description |
|
|
| ----- | ---- | ----------- |
|
|
| `id` | int64 | Channel ID |
|
|
| `name` | string | Channel name |
|
|
| `type` | string | `text`, `voice`, or `announcement` (announcement channels are read like text but only `MANAGE_MESSAGES` holders can post) |
|
|
| `topic` | string | Channel topic/description |
|
|
| `category` | string | Category grouping |
|
|
| `position` | int | Sort order within category |
|
|
| `slow_mode` | int | Slow-mode delay in seconds (0 = disabled) |
|
|
| `archived` | bool | Whether the channel is archived |
|
|
|
|
---
|
|
|
|
### GET /api/v1/channels/{id}/messages
|
|
|
|
Paginated message history for a channel.
|
|
|
|
**Auth:** Required
|
|
**Permission:** `READ_MESSAGES` on the channel (or DM participant membership)
|
|
|
|
#### Query Parameters
|
|
|
|
| Param | Type | Default | Range | Description |
|
|
| ----- | ---- | ------- | ----- | ----------- |
|
|
| `before` | int64 | 0 (latest) | >= 0 | Cursor: return messages with ID less than this value |
|
|
| `limit` | int | 50 | 1-100 | Number of messages to return |
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
{
|
|
"messages": [
|
|
{
|
|
"id": 1042,
|
|
"channel_id": 5,
|
|
"user": {
|
|
"id": 1,
|
|
"username": "alex",
|
|
"avatar": "uuid.png"
|
|
},
|
|
"content": "Hello!",
|
|
"reply_to": null,
|
|
"attachments": [
|
|
{
|
|
"id": "file-uuid",
|
|
"filename": "photo.jpg",
|
|
"size": 204800,
|
|
"mime_type": "image/jpeg",
|
|
"url": "/api/v1/files/file-uuid",
|
|
"width": 1920,
|
|
"height": 1080
|
|
}
|
|
],
|
|
"reactions": [
|
|
{
|
|
"emoji": "\ud83d\udc4d",
|
|
"count": 2,
|
|
"me": true
|
|
}
|
|
],
|
|
"pinned": false,
|
|
"edited_at": null,
|
|
"deleted": false,
|
|
"timestamp": "2026-03-14T10:30:00Z"
|
|
}
|
|
],
|
|
"has_more": true
|
|
}
|
|
```
|
|
|
|
#### Pagination
|
|
|
|
Use cursor-based pagination by passing the `id` of the last message as the `before` parameter:
|
|
|
|
```
|
|
GET /api/v1/channels/5/messages?before=1042&limit=50
|
|
```
|
|
|
|
When `has_more` is `false`, you have reached the beginning of the channel history.
|
|
|
|
---
|
|
|
|
### GET /api/v1/channels/{id}/pins
|
|
|
|
Get all pinned messages for a channel.
|
|
|
|
**Auth:** Required
|
|
**Permission:** `READ_MESSAGES` on the channel
|
|
|
|
#### Response 200 OK
|
|
|
|
Returns `{ "messages": [...], "has_more": false }`. `has_more` is always `false` for pins (all pinned messages are returned at once).
|
|
|
|
---
|
|
|
|
### POST /api/v1/channels/{id}/pins/{messageId}
|
|
|
|
Pin a message in a channel.
|
|
|
|
**Auth:** Required
|
|
**Permission:** `MANAGE_MESSAGES` on the channel
|
|
|
|
#### Response 204 No Content
|
|
|
|
---
|
|
|
|
### DELETE /api/v1/channels/{id}/pins/{messageId}
|
|
|
|
Unpin a message from a channel.
|
|
|
|
**Auth:** Required
|
|
**Permission:** `MANAGE_MESSAGES` on the channel
|
|
|
|
#### Response 204 No Content
|
|
|
|
---
|
|
|
|
## Search
|
|
|
|
### GET /api/v1/search
|
|
|
|
Full-text search across messages in channels the user can read. Uses SQLite FTS5 for matching.
|
|
|
|
**Auth:** Required
|
|
**Rate limit:** 30 requests/minute
|
|
|
|
#### Query Parameters
|
|
|
|
| Param | Type | Default | Range | Description |
|
|
| ----- | ---- | ------- | ----- | ----------- |
|
|
| `q` | string | (required) | non-empty | Search query (FTS5 syntax) |
|
|
| `channel_id` | int64 | (all channels) | > 0 | Restrict search to a single channel |
|
|
| `limit` | int | 50 | 1-100 | Maximum results to return |
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
{
|
|
"results": [
|
|
{
|
|
"message_id": 1042,
|
|
"channel_id": 5,
|
|
"channel_name": "general",
|
|
"user": {
|
|
"id": 1,
|
|
"username": "alex"
|
|
},
|
|
"content": "...matched text...",
|
|
"timestamp": "2026-03-14T10:30:00Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## GIFs
|
|
|
|
The server proxies the Klipy GIF API so the provider API key stays server-side.
|
|
Clients never contact `api.klipy.com` — a key shipped in the desktop bundle
|
|
would be public by construction. The key is configured as `gif.api_key`
|
|
(see [Server Configuration](server-configuration.md#gif-picker-gif)).
|
|
|
|
**Default-off contract:** with no key configured, both endpoints return
|
|
`503` with error code `GIF_DISABLED`. Clients must treat that as "this server
|
|
does not have GIFs" and hide/disable the GIF affordance — not retry.
|
|
|
|
The media URLs in the response point at Klipy's CDN; the client still validates
|
|
them against its `klipy.com` CDN allowlist before rendering.
|
|
|
|
### GET /api/v1/gif/search
|
|
|
|
**Auth:** Required
|
|
**Rate limit:** 30 requests/minute (dedicated per-IP bucket)
|
|
|
|
#### Query Parameters
|
|
|
|
| Param | Type | Default | Range | Description |
|
|
| ----- | ---- | ------- | ----- | ----------- |
|
|
| `q` | string | (required) | 1-100 chars | Search term |
|
|
| `limit` | int | 20 | 1-50 | Maximum results to return |
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
{
|
|
"results": [
|
|
{
|
|
"id": "abc123",
|
|
"title": "happy cat",
|
|
"media_formats": {
|
|
"tinygif": { "url": "https://media.klipy.com/abc123_tiny.gif" },
|
|
"gif": { "url": "https://media.klipy.com/abc123.gif" }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Only `id`, `title`, and the two `media_formats` URLs are forwarded. Every other
|
|
field the upstream returns is dropped, so an upstream that echoed the API key
|
|
could not leak it to clients. Results missing either format are omitted.
|
|
|
|
#### Errors
|
|
|
|
| Status | Code | When |
|
|
| ------ | ---- | ---- |
|
|
| 400 | `INVALID_INPUT` | Missing/blank `q`, `q` over 100 chars, or `limit` outside 1-50 |
|
|
| 401 | `UNAUTHORIZED` | No valid session (checked before the disabled check) |
|
|
| 429 | `RATE_LIMITED` | Over 30 requests/minute |
|
|
| 502 | `BAD_GATEWAY` | Upstream error, timeout, or unparseable response |
|
|
| 503 | `GIF_DISABLED` | `gif.api_key` is not configured |
|
|
|
|
### GET /api/v1/gif/trending
|
|
|
|
Same auth, rate limit, response shape, and error codes as
|
|
`/api/v1/gif/search`, minus the `q` parameter.
|
|
|
|
| Param | Type | Default | Range | Description |
|
|
| ----- | ---- | ------- | ----- | ----------- |
|
|
| `limit` | int | 20 | 1-50 | Maximum results to return |
|
|
|
|
---
|
|
|
|
## Direct Messages
|
|
|
|
DM channels use participant-based authorization rather than role-based permissions.
|
|
|
|
### POST /api/v1/dms
|
|
|
|
Create or retrieve a 1-on-1 DM channel with another user. If a DM channel already exists, it is returned and re-opened.
|
|
|
|
**Auth:** Required
|
|
|
|
#### Request
|
|
|
|
```json
|
|
{
|
|
"recipient_id": 2
|
|
}
|
|
```
|
|
|
|
#### Response 200 OK (existing channel) or 201 Created (new channel)
|
|
|
|
```json
|
|
{
|
|
"channel_id": 100,
|
|
"recipient": {
|
|
"id": 2,
|
|
"username": "jordan",
|
|
"avatar": "uuid.png",
|
|
"status": "online"
|
|
},
|
|
"created": false
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### GET /api/v1/dms
|
|
|
|
List all open DM channels for the authenticated user, ordered by most recent activity.
|
|
|
|
**Auth:** Required
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
{
|
|
"dm_channels": [
|
|
{
|
|
"channel_id": 100,
|
|
"recipient": {
|
|
"id": 2,
|
|
"username": "jordan",
|
|
"avatar": "uuid.png",
|
|
"status": "online"
|
|
},
|
|
"last_message_id": 5042,
|
|
"last_message": "Hey, how's it going?",
|
|
"last_message_at": "2026-03-28T14:30:00Z",
|
|
"unread_count": 3
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### DELETE /api/v1/dms/{channelId}
|
|
|
|
Close a DM channel for the authenticated user (hides it from their sidebar). The channel and messages remain in the database. If the other user sends a new message, the channel is automatically re-opened.
|
|
|
|
**Auth:** Required
|
|
|
|
#### Response 204 No Content
|
|
|
|
---
|
|
|
|
## User Blocks
|
|
|
|
Blocking a user prevents DM creation and messaging in both directions
|
|
(backed by the `user_blocks` table).
|
|
|
|
### GET /api/v1/blocks
|
|
|
|
List the IDs of users the authenticated user has blocked.
|
|
|
|
**Auth:** Required
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
{ "blocked_user_ids": [2, 7] }
|
|
```
|
|
|
|
---
|
|
|
|
### PUT /api/v1/blocks/{userId}
|
|
|
|
Block a user.
|
|
|
|
**Auth:** Required
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
{ "message": "user blocked" }
|
|
```
|
|
|
|
---
|
|
|
|
### DELETE /api/v1/blocks/{userId}
|
|
|
|
Unblock a user.
|
|
|
|
**Auth:** Required
|
|
|
|
---
|
|
|
|
## Invite Endpoints
|
|
|
|
All invite endpoints require authentication and the `MANAGE_INVITES` permission.
|
|
|
|
### POST /api/v1/invites
|
|
|
|
Create a new invite code.
|
|
|
|
**Auth:** Required
|
|
**Permission:** `MANAGE_INVITES`
|
|
|
|
#### Request
|
|
|
|
```json
|
|
{
|
|
"max_uses": 5,
|
|
"expires_in_hours": 48
|
|
}
|
|
```
|
|
|
|
Both fields are optional. An empty body creates an invite with unlimited uses and no expiry.
|
|
|
|
#### Response 201 Created
|
|
|
|
```json
|
|
{
|
|
"id": 1,
|
|
"code": "abc123def",
|
|
"max_uses": 5,
|
|
"uses": 0,
|
|
"expires_at": "2026-03-30T10:30:00Z",
|
|
"revoked": false,
|
|
"created_at": "2026-03-28T10:30:00Z"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### GET /api/v1/invites
|
|
|
|
List all invites (active, expired, and revoked).
|
|
|
|
**Auth:** Required
|
|
**Permission:** `MANAGE_INVITES`
|
|
|
|
#### Response 200 OK
|
|
|
|
Returns a JSON array of invite objects.
|
|
|
|
---
|
|
|
|
### DELETE /api/v1/invites/{code}
|
|
|
|
Revoke an invite by its code string.
|
|
|
|
**Auth:** Required
|
|
**Permission:** `MANAGE_INVITES`
|
|
|
|
#### Response 204 No Content
|
|
|
|
---
|
|
|
|
## File Upload and Serving
|
|
|
|
### POST /api/v1/uploads
|
|
|
|
Upload a file as multipart form data.
|
|
|
|
**Auth:** Required
|
|
**Rate limit:** 10 requests/minute
|
|
**Body size limit:** 100 MiB
|
|
**Content-Type:** `multipart/form-data`
|
|
|
|
Files are validated against blocked magic bytes (PE executables, ELF binaries, Mach-O binaries, shell scripts). Files are stored with UUID filenames.
|
|
|
|
#### Response 201 Created
|
|
|
|
```json
|
|
{
|
|
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
"filename": "photo.jpg",
|
|
"size": 204800,
|
|
"mime": "image/jpeg",
|
|
"url": "/api/v1/files/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
"width": 1920,
|
|
"height": 1080
|
|
}
|
|
```
|
|
|
|
`width` and `height` are only present for image files.
|
|
|
|
---
|
|
|
|
### GET /api/v1/files/{id}
|
|
|
|
Serve a previously uploaded file by its UUID.
|
|
|
|
**Auth:** Required (Bearer token) — downloads are access-controlled
|
|
**Caching:** `Cache-Control: private, no-cache` (never stored by shared/proxy caches; browsers must revalidate)
|
|
|
|
Supports HTTP range requests and conditional requests. MIME types that could
|
|
execute under the app origin (HTML, SVG, XML, PDF) are served with
|
|
`Content-Disposition: attachment` to force download.
|
|
|
|
---
|
|
|
|
## Health Check
|
|
|
|
### GET /health
|
|
|
|
### GET /api/v1/health
|
|
|
|
Public health check endpoint, no authentication required. The server version
|
|
is deliberately not exposed here (anti-fingerprinting hardening, C-2).
|
|
|
|
```json
|
|
{
|
|
"status": "ok",
|
|
"uptime": 86400,
|
|
"online_users": 3
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Server Info
|
|
|
|
### GET /api/v1/info
|
|
|
|
Returns the server name. The version field was removed from this
|
|
unauthenticated endpoint (anti-fingerprinting hardening, C-2).
|
|
|
|
**Auth:** None
|
|
|
|
```json
|
|
{
|
|
"name": "My OwnCord Server"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Metrics
|
|
|
|
### GET /api/v1/metrics
|
|
|
|
Runtime server metrics. Restricted to admin-allowed CIDRs.
|
|
|
|
**Auth:** Admin IP restriction (not token-based)
|
|
|
|
```json
|
|
{
|
|
"uptime": "2h30m15s",
|
|
"uptime_seconds": 9015.0,
|
|
"goroutines": 42,
|
|
"heap_alloc_mb": 12.5,
|
|
"heap_sys_mb": 24.0,
|
|
"num_gc": 156,
|
|
"connected_users": 8,
|
|
"livekit_healthy": true
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Plugin Administration
|
|
|
|
Manage WASM plugins. These endpoints sit behind **both** the admin IP
|
|
restriction (allowed CIDRs) **and** admin bearer-token authentication.
|
|
Plugin execution additionally requires a server built with `-tags wazero`
|
|
and `plugins.enabled: true` in config.
|
|
|
|
### GET /api/v1/admin/plugins
|
|
|
|
List installed plugins.
|
|
|
|
#### Response 200 OK
|
|
|
|
Array of plugin rows: `ID`, `Name`, `Version`, `Enabled`, `ManifestJSON`,
|
|
`InstalledAt`.
|
|
|
|
### POST /api/v1/admin/plugins/install
|
|
|
|
Install a plugin from an uploaded zip (multipart form). The archive is
|
|
size-capped (16 MiB compressed / 64 MiB uncompressed) and hardened against
|
|
zip-slip and symlinks; installation is staged and atomically renamed.
|
|
|
|
#### Response 201 Created
|
|
|
|
```json
|
|
{ "name": "plugin-name" }
|
|
```
|
|
|
|
### POST /api/v1/admin/plugins/{id}/enable
|
|
|
|
### POST /api/v1/admin/plugins/{id}/disable
|
|
|
|
Enable or disable an installed plugin.
|
|
|
|
### DELETE /api/v1/admin/plugins/{id}
|
|
|
|
Uninstall a plugin.
|
|
|
|
---
|
|
|
|
## LiveKit Endpoints
|
|
|
|
These endpoints are only registered when LiveKit voice is configured.
|
|
|
|
### POST /api/v1/livekit/webhook
|
|
|
|
LiveKit webhook receiver. Uses LiveKit JWT verification. Admin-IP-restricted. Called by the LiveKit server, not by clients.
|
|
|
|
### GET /api/v1/livekit/health
|
|
|
|
Check whether the LiveKit server is reachable.
|
|
|
|
**Auth:** Admin IP restriction
|
|
|
|
#### Response 200 OK
|
|
|
|
```json
|
|
{
|
|
"status": "ok",
|
|
"livekit_reachable": true
|
|
}
|
|
```
|
|
|
|
#### Response 503 Service Unavailable
|
|
|
|
```json
|
|
{
|
|
"status": "degraded",
|
|
"livekit_reachable": false,
|
|
"error": "connection refused"
|
|
}
|
|
```
|
|
|
|
### /livekit/* (Reverse Proxy)
|
|
|
|
All requests to `/livekit/*` are reverse-proxied to the LiveKit server URL. The `/livekit` prefix is stripped before forwarding. This allows the client to connect to LiveKit through OwnCord's HTTPS server, avoiding mixed-content blocks.
|
|
|
|
**Auth:** None (LiveKit handles its own JWT-based auth)
|
|
**Rate limit:** 30 requests/minute per IP
|
|
|
|
---
|
|
|
|
## Diagnostics
|
|
|
|
### GET /api/v1/diagnostics/connectivity
|
|
|
|
Returns connectivity diagnostics for debugging voice/network issues.
|
|
|
|
**Auth:** Required (any authenticated user)
|
|
**Rate limit:** 5 requests/minute per user
|
|
|
|
```json
|
|
{
|
|
"server": {
|
|
"version": "1.0.0",
|
|
"uptime_s": 3600,
|
|
"go_version": "go1.23.0",
|
|
"online_users": 5
|
|
},
|
|
"voice": {
|
|
"enabled": true,
|
|
"livekit_url": "ws://localhost:7880",
|
|
"livekit_health": true,
|
|
"node_ip": "203.0.113.1",
|
|
"proxy_path": "/livekit"
|
|
},
|
|
"client": {
|
|
"remote_addr": "192.168.1.100",
|
|
"is_private_network": true
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Client Auto-Update
|
|
|
|
### GET /api/v1/client-update/{target}/{current_version}
|
|
|
|
Tauri-compatible update endpoint. The desktop client checks this to see if a newer version is available.
|
|
|
|
**Auth:** None
|
|
|
|
#### Path Parameters
|
|
|
|
| Param | Type | Description |
|
|
| ----- | ---- | ----------- |
|
|
| `target` | string | Tauri updater target `{os}-{arch}-{installer}` (e.g., `windows-x86_64-nsis`, `linux-x86_64-appimage`, `linux-aarch64-appimage`). Selects the platform's updater artifact and is echoed back as the `platforms` key. Targets without a published updater artifact (e.g., `linux-x86_64-deb`) get 204. |
|
|
| `current_version` | string | Client's current semver version (e.g., `1.0.0`) |
|
|
|
|
#### Response 200 OK (update available)
|
|
|
|
```json
|
|
{
|
|
"version": "1.2.0",
|
|
"notes": "## What's Changed\n...",
|
|
"pub_date": "2026-03-28T00:00:00Z",
|
|
"platforms": {
|
|
"windows-x86_64-nsis": {
|
|
"signature": "base64-encoded-signature",
|
|
"url": "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/OwnCord_1.2.0_x64-setup.nsis.zip"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Response 204 No Content
|
|
|
|
Client is already up-to-date, or no client build is published for `target`.
|
|
|
|
---
|
|
|
|
## WebSocket
|
|
|
|
### GET /api/v1/ws
|
|
|
|
WebSocket upgrade endpoint. Authentication is performed in-band (first message must be an `auth` frame with the session token). See [protocol.md](protocol.md) for the full WebSocket message protocol.
|