diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index b5541d07c..f42f6e7df 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -4,6 +4,7 @@ "title": "About" }, "admin": { + "api_keys_description": "Create, review, and revoke API keys for external agents that need content access.", "back_to_site": "Back to site", "compat_tasks_description": "Run compatibility jobs in bulk to backfill missing AI summaries and image blurhash metadata for older posts.", "health_description": "Review required configuration, missing integrations, and risks that can affect runtime behavior.", @@ -12,6 +13,68 @@ "title": "Admin", "writing_description": "Write, edit, and publish content from the private management workspace." }, + "api_keys": { + "title": "API Keys", + "loading": "Loading API keys...", + "empty": "No API keys have been created yet.", + "copied": "Copied to clipboard.", + "never": "Never", + "revoke": "Revoke", + "create": { + "title": "Create Agent API Key", + "description": "Generate a bearer token for an external agent that needs to read or write Rin content.", + "name_label": "Label", + "name_placeholder": "Agent Writer", + "expiry_label": "Expiration", + "submit": "Create key", + "creating": "Creating..." + }, + "expiry": { + "never": "Never expires", + "30d": "Expires in 30 days", + "90d": "Expires in 90 days", + "7d": "Expires in 7 days", + "14d": "Expires in 14 days", + "180d": "Expires in 180 days", + "365d": "Expires in 365 days" + }, + "secret": { + "title": "Copy the secret now", + "description": "Rin only shows the full key once. Store it in your agent tool before dismissing this message.", + "copy": "Copy key", + "dismiss": "Dismiss" + }, + "list": { + "title": "Existing keys", + "description": "Use this list to review active keys, last use, and expiration." + }, + "status": { + "active": "Active", + "revoked": "Revoked" + }, + "fields": { + "created_at": "Created: {{date}}", + "last_used_at": "Last used: {{date}}", + "expires_at": "Expires: {{date}}", + "capabilities": "Capabilities: {{scopes}}" + }, + "docs": { + "title": "Agent setup", + "description": "Built-in instructions for external agents that use Rin with an API key.", + "scope_note": "These keys are intended for blog content workflows such as creating posts, updating drafts, publishing moments, and uploading media.", + "routes_note": "Admin configuration routes stay blocked for API-key auth. Use an interactive admin session for settings and diagnostics.", + "copy_curl": "Copy curl example", + "open_skill": "Open bundled skill" + }, + "revoke_confirm": { + "title": "Revoke API key", + "description": "Revoke {{name}}? External agents using it will stop working immediately." + }, + "errors": { + "name_required": "A label is required.", + "create_failed": "Failed to create API key." + } + }, "compat_tasks": { "title": "Compatibility Tasks", "loading": "Loading compatibility task status...", @@ -281,47 +344,47 @@ "success": "Favicon update was successful" } }, - "footer": { - "desc": "Set the footer content of the site (HTML)", - "title": "Footer" - }, - "webhook": { - "title": "Webhook", - "url": { - "title": "Webhook URL", - "desc": "Set the webhook endpoint used for comment and friend-link notifications. Template variables are supported here too, which is useful for GET query strings." - }, - "method": { - "title": "Webhook Method", - "desc": "Set the HTTP method used for webhook requests, such as POST, PUT, or PATCH" - }, - "content_type": { - "title": "Webhook Content-Type", - "desc": "Set the Content-Type header sent with webhook requests, for example application/json or text/plain" - }, - "headers": { - "title": "Webhook Headers", - "desc": "Customize webhook headers as a JSON object template. Available variables are the same as the body template.", - "label": "Webhook headers JSON" - }, - "body_template": { - "title": "Webhook Body Template", - "desc": "Customize the outgoing webhook payload. Available variables: {{event}}, {{message}}, {{title}}, {{url}}, {{username}}, {{content}}, {{description}}.", - "label": "Webhook body template" - }, - "test": { - "title": "Send Test Webhook", - "desc": "Send a test request with the current webhook settings. Unsaved values in this page are used directly.", - "button": "Send Test", - "sending": "Sending...", - "placeholder": "Optional test message. Leave empty to use the default test payload.", - "success": "Webhook test sent successfully", - "failed": "Webhook test failed" - } - }, - "maintenance": { - "title": "Maintenance" - }, + "footer": { + "desc": "Set the footer content of the site (HTML)", + "title": "Footer" + }, + "webhook": { + "title": "Webhook", + "url": { + "title": "Webhook URL", + "desc": "Set the webhook endpoint used for comment and friend-link notifications. Template variables are supported here too, which is useful for GET query strings." + }, + "method": { + "title": "Webhook Method", + "desc": "Set the HTTP method used for webhook requests, such as POST, PUT, or PATCH" + }, + "content_type": { + "title": "Webhook Content-Type", + "desc": "Set the Content-Type header sent with webhook requests, for example application/json or text/plain" + }, + "headers": { + "title": "Webhook Headers", + "desc": "Customize webhook headers as a JSON object template. Available variables are the same as the body template.", + "label": "Webhook headers JSON" + }, + "body_template": { + "title": "Webhook Body Template", + "desc": "Customize the outgoing webhook payload. Available variables: {{event}}, {{message}}, {{title}}, {{url}}, {{username}}, {{content}}, {{description}}.", + "label": "Webhook body template" + }, + "test": { + "title": "Send Test Webhook", + "desc": "Send a test request with the current webhook settings. Unsaved values in this page are used directly.", + "button": "Send Test", + "sending": "Sending...", + "placeholder": "Optional test message. Leave empty to use the default test payload.", + "success": "Webhook test sent successfully", + "failed": "Webhook test failed" + } + }, + "maintenance": { + "title": "Maintenance" + }, "friend": { "apply": { "desc": "Allow other users to apply for friend links (requires review)", @@ -574,33 +637,33 @@ "summary": "login.enabled is disabled.", "suggestion": "Enable login in settings if this instance needs interactive admin access." }, - "missing": { - "impact": "Users can see the login entry but no working login method is configured.", - "summary": "Neither GitHub OAuth nor password login is configured.", - "suggestion": "Configure RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET or ADMIN_USERNAME/ADMIN_PASSWORD." - }, - "default_password": { - "impact": "The admin account is still using the default username and password, which is a critical security risk.", - "summary": "ADMIN_USERNAME/ADMIN_PASSWORD are still set to admin / admin123.", - "suggestion": "Change the default username or password immediately before exposing the site." - }, - "oauth_missing": { - "impact": "Only the local password account can sign in. Other users cannot log in and comment through OAuth.", - "summary": "GitHub OAuth is not configured.", - "suggestion": "Configure RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET if you expect other users to log in." - }, - "ready": { - "impact": "Admin sign-in is available.", - "summary": "OAuth login is configured and the current password login configuration is not using the default credentials.", - "suggestion": "No action required." - }, - "details": { - "github_configured": "GitHub OAuth: configured", - "github_missing": "GitHub OAuth: missing", - "password_configured": "Password login: configured", - "password_missing": "Password login: missing", - "password_default": "Password login: using default credentials" - } + "missing": { + "impact": "Users can see the login entry but no working login method is configured.", + "summary": "Neither GitHub OAuth nor password login is configured.", + "suggestion": "Configure RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET or ADMIN_USERNAME/ADMIN_PASSWORD." + }, + "default_password": { + "impact": "The admin account is still using the default username and password, which is a critical security risk.", + "summary": "ADMIN_USERNAME/ADMIN_PASSWORD are still set to admin / admin123.", + "suggestion": "Change the default username or password immediately before exposing the site." + }, + "oauth_missing": { + "impact": "Only the local password account can sign in. Other users cannot log in and comment through OAuth.", + "summary": "GitHub OAuth is not configured.", + "suggestion": "Configure RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET if you expect other users to log in." + }, + "ready": { + "impact": "Admin sign-in is available.", + "summary": "OAuth login is configured and the current password login configuration is not using the default credentials.", + "suggestion": "No action required." + }, + "details": { + "github_configured": "GitHub OAuth: configured", + "github_missing": "GitHub OAuth: missing", + "password_configured": "Password login: configured", + "password_missing": "Password login: missing", + "password_default": "Password login: using default credentials" + } }, "storage": { "title": "Object storage", diff --git a/client/public/locales/ja/translation.json b/client/public/locales/ja/translation.json index 0dbfec033..a0e75726b 100644 --- a/client/public/locales/ja/translation.json +++ b/client/public/locales/ja/translation.json @@ -10,7 +10,8 @@ "queue_status_description": "AI 要約キューの進行状況、最近のタスク結果、失敗内容を確認します。", "settings_description": "サイトの挙動、連携設定、公開ルールを一か所で管理します。", "title": "管理画面", - "writing_description": "非公開の管理ワークスペースから記事を作成、編集、公開します。" + "writing_description": "非公開の管理ワークスペースから記事を作成、編集、公開します。", + "api_keys_description": "外部エージェント向けの API キーを作成、確認、無効化します。" }, "compat_tasks": { "title": "互換タスク", @@ -272,47 +273,47 @@ "success": "ファビコンの更新が成功しました" } }, - "footer": { - "desc": "サイトのフッターコンテンツを設定する(HTML)", - "title": "フッター" - }, - "webhook": { - "title": "Webhook", - "url": { - "title": "Webhook URL", - "desc": "コメント通知と友達リンク通知に使う webhook URL を設定します。ここでもテンプレート変数が使えるので、GET の query string にも利用できます。" - }, - "method": { - "title": "Webhook Method", - "desc": "webhook リクエストで使う HTTP Method を設定します。GET、POST、PUT、PATCH などに対応します。" - }, - "content_type": { - "title": "Webhook Content-Type", - "desc": "Webhook リクエスト送信時の Content-Type を設定します。例: application/json, text/plain" - }, - "headers": { - "title": "Webhook Headers", - "desc": "JSON テンプレートで webhook ヘッダーをカスタマイズします。使える変数は本文テンプレートと同じです。", - "label": "Webhook headers JSON" - }, - "body_template": { - "title": "Webhook ボディテンプレート", - "desc": "送信する webhook リクエスト本文をカスタマイズします。使用可能変数: {{event}}, {{message}}, {{title}}, {{url}}, {{username}}, {{content}}, {{description}}。", - "label": "Webhook ボディテンプレート" - }, - "test": { - "title": "テスト Webhook を送信", - "desc": "このページ上の現在の webhook 設定でテストリクエストを送信します。未保存の値もそのまま使われます。", - "button": "テスト送信", - "sending": "送信中...", - "placeholder": "任意のテストメッセージ。空の場合は既定のテスト payload を使います。", - "success": "Webhook テストの送信に成功しました", - "failed": "Webhook テストの送信に失敗しました" - } - }, - "maintenance": { - "title": "メンテナンス" - }, + "footer": { + "desc": "サイトのフッターコンテンツを設定する(HTML)", + "title": "フッター" + }, + "webhook": { + "title": "Webhook", + "url": { + "title": "Webhook URL", + "desc": "コメント通知と友達リンク通知に使う webhook URL を設定します。ここでもテンプレート変数が使えるので、GET の query string にも利用できます。" + }, + "method": { + "title": "Webhook Method", + "desc": "webhook リクエストで使う HTTP Method を設定します。GET、POST、PUT、PATCH などに対応します。" + }, + "content_type": { + "title": "Webhook Content-Type", + "desc": "Webhook リクエスト送信時の Content-Type を設定します。例: application/json, text/plain" + }, + "headers": { + "title": "Webhook Headers", + "desc": "JSON テンプレートで webhook ヘッダーをカスタマイズします。使える変数は本文テンプレートと同じです。", + "label": "Webhook headers JSON" + }, + "body_template": { + "title": "Webhook ボディテンプレート", + "desc": "送信する webhook リクエスト本文をカスタマイズします。使用可能変数: {{event}}, {{message}}, {{title}}, {{url}}, {{username}}, {{content}}, {{description}}。", + "label": "Webhook ボディテンプレート" + }, + "test": { + "title": "テスト Webhook を送信", + "desc": "このページ上の現在の webhook 設定でテストリクエストを送信します。未保存の値もそのまま使われます。", + "button": "テスト送信", + "sending": "送信中...", + "placeholder": "任意のテストメッセージ。空の場合は既定のテスト payload を使います。", + "success": "Webhook テストの送信に成功しました", + "failed": "Webhook テストの送信に失敗しました" + } + }, + "maintenance": { + "title": "メンテナンス" + }, "friend": { "apply": { "desc": "他のユーザーが友人リンクを申請できるようにする(審査が必要)", @@ -565,33 +566,33 @@ "summary": "login.enabled が無効です。", "suggestion": "このインスタンスで対話的な管理アクセスが必要なら、設定でログインを有効にしてください。" }, - "missing": { - "impact": "ログイン入口は表示されますが、利用可能なログイン方法がありません。", - "summary": "GitHub OAuth とパスワードログインのどちらも設定されていません。", - "suggestion": "RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET または ADMIN_USERNAME/ADMIN_PASSWORD を設定してください。" - }, - "default_password": { - "impact": "管理者アカウントがまだ既定のユーザー名とパスワードを使っており、重大なセキュリティリスクです。", - "summary": "ADMIN_USERNAME/ADMIN_PASSWORD が admin / admin123 のままです。", - "suggestion": "サイトを公開する前に既定のユーザー名またはパスワードを直ちに変更してください。" - }, - "oauth_missing": { - "impact": "現在はローカルのパスワードアカウントしか使えず、他のユーザーは OAuth でログインしてコメントできません。", - "summary": "GitHub OAuth が設定されていません。", - "suggestion": "他のユーザーにもログインを許可するなら、RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET を設定してください。" - }, - "ready": { - "impact": "管理ログインが利用可能です。", - "summary": "OAuth ログインが設定済みで、現在のパスワードログインも既定の認証情報を使っていません。", - "suggestion": "対応は不要です。" - }, - "details": { - "github_configured": "GitHub OAuth: 設定済み", - "github_missing": "GitHub OAuth: 未設定", - "password_configured": "パスワードログイン: 設定済み", - "password_missing": "パスワードログイン: 未設定", - "password_default": "パスワードログイン: 既定の認証情報を使用中" - } + "missing": { + "impact": "ログイン入口は表示されますが、利用可能なログイン方法がありません。", + "summary": "GitHub OAuth とパスワードログインのどちらも設定されていません。", + "suggestion": "RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET または ADMIN_USERNAME/ADMIN_PASSWORD を設定してください。" + }, + "default_password": { + "impact": "管理者アカウントがまだ既定のユーザー名とパスワードを使っており、重大なセキュリティリスクです。", + "summary": "ADMIN_USERNAME/ADMIN_PASSWORD が admin / admin123 のままです。", + "suggestion": "サイトを公開する前に既定のユーザー名またはパスワードを直ちに変更してください。" + }, + "oauth_missing": { + "impact": "現在はローカルのパスワードアカウントしか使えず、他のユーザーは OAuth でログインしてコメントできません。", + "summary": "GitHub OAuth が設定されていません。", + "suggestion": "他のユーザーにもログインを許可するなら、RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET を設定してください。" + }, + "ready": { + "impact": "管理ログインが利用可能です。", + "summary": "OAuth ログインが設定済みで、現在のパスワードログインも既定の認証情報を使っていません。", + "suggestion": "対応は不要です。" + }, + "details": { + "github_configured": "GitHub OAuth: 設定済み", + "github_missing": "GitHub OAuth: 未設定", + "password_configured": "パスワードログイン: 設定済み", + "password_missing": "パスワードログイン: 未設定", + "password_default": "パスワードログイン: 既定の認証情報を使用中" + } }, "storage": { "title": "オブジェクトストレージ", @@ -710,5 +711,67 @@ }, "sort_latest": "新着", "sort_popular": "人気", - "total_tags": "全 {{count}} 個のタグ" + "total_tags": "全 {{count}} 個のタグ", + "api_keys": { + "title": "API キー", + "loading": "API キーを読み込んでいます...", + "empty": "まだ API キーは作成されていません。", + "copied": "クリップボードにコピーしました。", + "never": "なし", + "revoke": "無効化", + "create": { + "title": "エージェント用 API キーを作成", + "description": "Rin のコンテンツを読み書きする外部エージェント向け Bearer トークンを発行します。", + "name_label": "ラベル", + "name_placeholder": "Agent Writer", + "expiry_label": "有効期限", + "submit": "キーを作成", + "creating": "作成中..." + }, + "expiry": { + "never": "無期限", + "30d": "30 日後に期限切れ", + "90d": "90 日後に期限切れ", + "7d": "7 日後に期限切れ", + "14d": "14 日後に期限切れ", + "180d": "180 日後に期限切れ", + "365d": "365 日後に期限切れ" + }, + "secret": { + "title": "今すぐシークレットを保存してください", + "description": "完全なキーはこの一度だけ表示されます。閉じる前にエージェントツールへ保存してください。", + "copy": "キーをコピー", + "dismiss": "閉じる" + }, + "list": { + "title": "既存のキー", + "description": "状態、最終使用時刻、有効期限を確認できます。" + }, + "status": { + "active": "有効", + "revoked": "無効" + }, + "fields": { + "created_at": "作成日時: {{date}}", + "last_used_at": "最終使用: {{date}}", + "expires_at": "有効期限: {{date}}", + "capabilities": "権限: {{scopes}}" + }, + "docs": { + "title": "エージェント設定", + "description": "Rin を API キーで利用する外部エージェント向けの組み込み手順です。", + "scope_note": "これらのキーは、投稿作成、下書き更新、モーメント投稿、メディアアップロードなどのコンテンツ作業向けです。", + "routes_note": "管理設定系の API は API キーでは使えません。設定や診断は管理者セッションで行ってください。", + "copy_curl": "curl 例をコピー", + "open_skill": "組み込み Skill を開く" + }, + "revoke_confirm": { + "title": "API キーを無効化", + "description": "{{name}} を無効化しますか。このキーを使う外部エージェントはすぐに動作しなくなります。" + }, + "errors": { + "name_required": "ラベルを入力してください。", + "create_failed": "API キーの作成に失敗しました。" + } + } } diff --git a/client/public/locales/zh-CN/translation.json b/client/public/locales/zh-CN/translation.json index f5f7145fa..00d3736a3 100644 --- a/client/public/locales/zh-CN/translation.json +++ b/client/public/locales/zh-CN/translation.json @@ -4,6 +4,7 @@ "title": "关于" }, "admin": { + "api_keys_description": "为需要访问内容的外部智能体创建、查看和撤销 API Key。", "back_to_site": "返回站点", "compat_tasks_description": "批量触发向前兼容任务,用于补齐历史帖子缺失的 AI 摘要和图片 blurhash 元数据。", "health_description": "检查关键配置、缺失的集成项,以及可能影响服务正常运行的风险。", @@ -281,47 +282,47 @@ "success": "网站图标更新成功" } }, - "footer": { - "desc": "设置显示于站点底部的内容(HTML)", - "title": "站点底部内容" - }, - "webhook": { - "title": "Webhook", - "url": { - "title": "Webhook 地址", - "desc": "设置评论和友链通知使用的 webhook 地址。这里同样支持模板变量,适合 GET 场景拼接 query string。" - }, - "method": { - "title": "Webhook Method", - "desc": "设置 webhook 请求使用的 HTTP Method,例如 GET、POST、PUT 或 PATCH" - }, - "content_type": { - "title": "Webhook Content-Type", - "desc": "设置 webhook 请求发送时使用的 Content-Type,例如 application/json 或 text/plain" - }, - "headers": { - "title": "Webhook 请求头", - "desc": "以 JSON 模板自定义 webhook 请求头。可用变量与请求体模板一致。", - "label": "Webhook 请求头 JSON" - }, - "body_template": { - "title": "Webhook 请求体模板", - "desc": "自定义 webhook 发出的请求体。可用变量:{{event}}、{{message}}、{{title}}、{{url}}、{{username}}、{{content}}、{{description}}。", - "label": "Webhook 请求体模板" - }, - "test": { - "title": "发送测试 Webhook", - "desc": "使用当前页面里的 webhook 设置发送一次测试请求。未保存的修改也会直接生效。", - "button": "发送测试", - "sending": "发送中...", - "placeholder": "可选的测试消息内容。留空时会使用默认测试负载。", - "success": "Webhook 测试发送成功", - "failed": "Webhook 测试发送失败" - } - }, - "maintenance": { - "title": "维护" - }, + "footer": { + "desc": "设置显示于站点底部的内容(HTML)", + "title": "站点底部内容" + }, + "webhook": { + "title": "Webhook", + "url": { + "title": "Webhook 地址", + "desc": "设置评论和友链通知使用的 webhook 地址。这里同样支持模板变量,适合 GET 场景拼接 query string。" + }, + "method": { + "title": "Webhook Method", + "desc": "设置 webhook 请求使用的 HTTP Method,例如 GET、POST、PUT 或 PATCH" + }, + "content_type": { + "title": "Webhook Content-Type", + "desc": "设置 webhook 请求发送时使用的 Content-Type,例如 application/json 或 text/plain" + }, + "headers": { + "title": "Webhook 请求头", + "desc": "以 JSON 模板自定义 webhook 请求头。可用变量与请求体模板一致。", + "label": "Webhook 请求头 JSON" + }, + "body_template": { + "title": "Webhook 请求体模板", + "desc": "自定义 webhook 发出的请求体。可用变量:{{event}}、{{message}}、{{title}}、{{url}}、{{username}}、{{content}}、{{description}}。", + "label": "Webhook 请求体模板" + }, + "test": { + "title": "发送测试 Webhook", + "desc": "使用当前页面里的 webhook 设置发送一次测试请求。未保存的修改也会直接生效。", + "button": "发送测试", + "sending": "发送中...", + "placeholder": "可选的测试消息内容。留空时会使用默认测试负载。", + "success": "Webhook 测试发送成功", + "failed": "Webhook 测试发送失败" + } + }, + "maintenance": { + "title": "维护" + }, "friend": { "apply": { "desc": "允许其他用户申请友链(需审核)", @@ -574,33 +575,33 @@ "summary": "login.enabled 已关闭。", "suggestion": "如果此实例需要交互式后台访问,请在设置中启用登录。" }, - "missing": { - "impact": "用户可以看到登录入口,但没有任何可用的登录方式。", - "summary": "GitHub OAuth 和密码登录都未配置。", - "suggestion": "请配置 RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET 或 ADMIN_USERNAME/ADMIN_PASSWORD。" - }, - "default_password": { - "impact": "管理员账号仍在使用默认用户名和密码,属于严重安全风险。", - "summary": "ADMIN_USERNAME/ADMIN_PASSWORD 仍然是 admin / admin123。", - "suggestion": "在公开站点前请立即修改默认用户名或密码。" - }, - "oauth_missing": { - "impact": "当前只能使用本地密码账号登录,其他用户无法通过 OAuth 登录并评论。", - "summary": "GitHub OAuth 未配置。", - "suggestion": "如果希望其他用户登录,请配置 RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET。" - }, - "ready": { - "impact": "后台登录可用。", - "summary": "OAuth 登录已配置,且当前密码登录未使用默认凭据。", - "suggestion": "无需处理。" - }, - "details": { - "github_configured": "GitHub OAuth:已配置", - "github_missing": "GitHub OAuth:未配置", - "password_configured": "密码登录:已配置", - "password_missing": "密码登录:未配置", - "password_default": "密码登录:使用默认凭据" - } + "missing": { + "impact": "用户可以看到登录入口,但没有任何可用的登录方式。", + "summary": "GitHub OAuth 和密码登录都未配置。", + "suggestion": "请配置 RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET 或 ADMIN_USERNAME/ADMIN_PASSWORD。" + }, + "default_password": { + "impact": "管理员账号仍在使用默认用户名和密码,属于严重安全风险。", + "summary": "ADMIN_USERNAME/ADMIN_PASSWORD 仍然是 admin / admin123。", + "suggestion": "在公开站点前请立即修改默认用户名或密码。" + }, + "oauth_missing": { + "impact": "当前只能使用本地密码账号登录,其他用户无法通过 OAuth 登录并评论。", + "summary": "GitHub OAuth 未配置。", + "suggestion": "如果希望其他用户登录,请配置 RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET。" + }, + "ready": { + "impact": "后台登录可用。", + "summary": "OAuth 登录已配置,且当前密码登录未使用默认凭据。", + "suggestion": "无需处理。" + }, + "details": { + "github_configured": "GitHub OAuth:已配置", + "github_missing": "GitHub OAuth:未配置", + "password_configured": "密码登录:已配置", + "password_missing": "密码登录:未配置", + "password_default": "密码登录:使用默认凭据" + } }, "storage": { "title": "对象存储", @@ -719,5 +720,67 @@ }, "sort_latest": "最新", "sort_popular": "热门", - "total_tags": "共 {{count}} 个标签" + "total_tags": "共 {{count}} 个标签", + "api_keys": { + "title": "API Key", + "loading": "正在加载 API Key...", + "empty": "还没有创建任何 API Key。", + "copied": "已复制到剪贴板。", + "never": "永不", + "revoke": "撤销", + "create": { + "title": "创建智能体 API Key", + "description": "为需要读取或写入 Rin 内容的外部智能体生成 Bearer Token。", + "name_label": "名称", + "name_placeholder": "Agent Writer", + "expiry_label": "过期时间", + "submit": "创建 Key", + "creating": "创建中..." + }, + "expiry": { + "never": "永不过期", + "30d": "30 天后过期", + "90d": "90 天后过期", + "7d": "7 天后过期", + "14d": "14 天后过期", + "180d": "180 天后过期", + "365d": "365 天后过期" + }, + "secret": { + "title": "请立即复制密钥", + "description": "Rin 只会展示完整 Key 一次。关闭前请先保存到你的智能体工具中。", + "copy": "复制 Key", + "dismiss": "关闭" + }, + "list": { + "title": "已有 Key", + "description": "在这里查看 Key 状态、最近使用时间和过期时间。" + }, + "status": { + "active": "生效中", + "revoked": "已撤销" + }, + "fields": { + "created_at": "创建时间:{{date}}", + "last_used_at": "最近使用:{{date}}", + "expires_at": "过期时间:{{date}}", + "capabilities": "能力范围:{{scopes}}" + }, + "docs": { + "title": "智能体接入说明", + "description": "提供给外部智能体使用 Rin API Key 的内置说明。", + "scope_note": "这些 Key 面向内容工作流,例如创建文章、更新草稿、发布想法和上传媒体。", + "routes_note": "管理配置接口不会对 API Key 开放。设置和诊断仍需使用交互式管理员会话。", + "copy_curl": "复制 curl 示例", + "open_skill": "打开内置 Skill" + }, + "revoke_confirm": { + "title": "撤销 API Key", + "description": "确认撤销 {{name}} 吗?所有使用它的外部智能体会立即失效。" + }, + "errors": { + "name_required": "请填写名称。", + "create_failed": "创建 API Key 失败。" + } + } } diff --git a/client/public/locales/zh-TW/translation.json b/client/public/locales/zh-TW/translation.json index 579169d64..ff35e8f77 100644 --- a/client/public/locales/zh-TW/translation.json +++ b/client/public/locales/zh-TW/translation.json @@ -10,7 +10,8 @@ "queue_status_description": "查看 AI 摘要佇列進度、最近任務結果與失敗資訊。", "settings_description": "在統一的後台空間中管理站點行為、整合配置與發佈規則。", "title": "後台管理", - "writing_description": "在獨立的管理工作區中編寫、編輯並發佈內容。" + "writing_description": "在獨立的管理工作區中編寫、編輯並發佈內容。", + "api_keys_description": "為需要內容存取能力的外部代理建立、檢視與撤銷 API Key。" }, "compat_tasks": { "title": "相容任務", @@ -169,7 +170,7 @@ "listed": "列出在文章中", "load_more": "載入更多", "login": { - "error":{ + "error": { "empty": "請輸入用戶名和密碼", "failed": "登入失敗", "invalid": "用戶名或密碼錯誤", @@ -281,47 +282,47 @@ "success": "網站圖標更新成功" } }, - "footer": { - "desc": "設定顯示於網站底部的內容(HTML)", - "title": "網站底部內容" - }, - "webhook": { - "title": "Webhook", - "url": { - "title": "Webhook 位址", - "desc": "設定評論與友鏈通知使用的 webhook 位址。這裡同樣支援模板變數,適合在 GET 場景拼接 query string。" - }, - "method": { - "title": "Webhook Method", - "desc": "設定 webhook 請求使用的 HTTP Method,例如 GET、POST、PUT 或 PATCH" - }, - "content_type": { - "title": "Webhook Content-Type", - "desc": "設定 webhook 請求送出時使用的 Content-Type,例如 application/json 或 text/plain" - }, - "headers": { - "title": "Webhook 請求標頭", - "desc": "以 JSON 模板自訂 webhook 請求標頭。可用變數與請求體模板一致。", - "label": "Webhook 請求標頭 JSON" - }, - "body_template": { - "title": "Webhook 請求體模板", - "desc": "自訂 webhook 送出的請求體。可用變數:{{event}}、{{message}}、{{title}}、{{url}}、{{username}}、{{content}}、{{description}}。", - "label": "Webhook 請求體模板" - }, - "test": { - "title": "發送測試 Webhook", - "desc": "使用目前頁面中的 webhook 設定發送一次測試請求。尚未儲存的修改也會直接生效。", - "button": "發送測試", - "sending": "發送中...", - "placeholder": "可選的測試訊息內容。留空時會使用預設測試負載。", - "success": "Webhook 測試發送成功", - "failed": "Webhook 測試發送失敗" - } - }, - "maintenance": { - "title": "維護" - }, + "footer": { + "desc": "設定顯示於網站底部的內容(HTML)", + "title": "網站底部內容" + }, + "webhook": { + "title": "Webhook", + "url": { + "title": "Webhook 位址", + "desc": "設定評論與友鏈通知使用的 webhook 位址。這裡同樣支援模板變數,適合在 GET 場景拼接 query string。" + }, + "method": { + "title": "Webhook Method", + "desc": "設定 webhook 請求使用的 HTTP Method,例如 GET、POST、PUT 或 PATCH" + }, + "content_type": { + "title": "Webhook Content-Type", + "desc": "設定 webhook 請求送出時使用的 Content-Type,例如 application/json 或 text/plain" + }, + "headers": { + "title": "Webhook 請求標頭", + "desc": "以 JSON 模板自訂 webhook 請求標頭。可用變數與請求體模板一致。", + "label": "Webhook 請求標頭 JSON" + }, + "body_template": { + "title": "Webhook 請求體模板", + "desc": "自訂 webhook 送出的請求體。可用變數:{{event}}、{{message}}、{{title}}、{{url}}、{{username}}、{{content}}、{{description}}。", + "label": "Webhook 請求體模板" + }, + "test": { + "title": "發送測試 Webhook", + "desc": "使用目前頁面中的 webhook 設定發送一次測試請求。尚未儲存的修改也會直接生效。", + "button": "發送測試", + "sending": "發送中...", + "placeholder": "可選的測試訊息內容。留空時會使用預設測試負載。", + "success": "Webhook 測試發送成功", + "failed": "Webhook 測試發送失敗" + } + }, + "maintenance": { + "title": "維護" + }, "friend": { "apply": { "desc": "允許其他用戶申請友情連結(需審核)", @@ -574,33 +575,33 @@ "summary": "login.enabled 已關閉。", "suggestion": "如果此實例需要互動式後台存取,請在設定中啟用登入。" }, - "missing": { - "impact": "使用者可以看到登入入口,但沒有任何可用的登入方式。", - "summary": "GitHub OAuth 和密碼登入都未配置。", - "suggestion": "請配置 RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET 或 ADMIN_USERNAME/ADMIN_PASSWORD。" - }, - "default_password": { - "impact": "管理員帳號仍在使用預設使用者名稱與密碼,屬於嚴重安全風險。", - "summary": "ADMIN_USERNAME/ADMIN_PASSWORD 仍然是 admin / admin123。", - "suggestion": "在公開網站之前請立即修改預設使用者名稱或密碼。" - }, - "oauth_missing": { - "impact": "目前只能使用本地密碼帳號登入,其他使用者無法透過 OAuth 登入並留言。", - "summary": "GitHub OAuth 未配置。", - "suggestion": "如果希望其他使用者登入,請配置 RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET。" - }, - "ready": { - "impact": "後台登入可用。", - "summary": "OAuth 登入已配置,且目前密碼登入未使用預設憑證。", - "suggestion": "無需處理。" - }, - "details": { - "github_configured": "GitHub OAuth:已配置", - "github_missing": "GitHub OAuth:未配置", - "password_configured": "密碼登入:已配置", - "password_missing": "密碼登入:未配置", - "password_default": "密碼登入:使用預設憑證" - } + "missing": { + "impact": "使用者可以看到登入入口,但沒有任何可用的登入方式。", + "summary": "GitHub OAuth 和密碼登入都未配置。", + "suggestion": "請配置 RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET 或 ADMIN_USERNAME/ADMIN_PASSWORD。" + }, + "default_password": { + "impact": "管理員帳號仍在使用預設使用者名稱與密碼,屬於嚴重安全風險。", + "summary": "ADMIN_USERNAME/ADMIN_PASSWORD 仍然是 admin / admin123。", + "suggestion": "在公開網站之前請立即修改預設使用者名稱或密碼。" + }, + "oauth_missing": { + "impact": "目前只能使用本地密碼帳號登入,其他使用者無法透過 OAuth 登入並留言。", + "summary": "GitHub OAuth 未配置。", + "suggestion": "如果希望其他使用者登入,請配置 RIN_GITHUB_CLIENT_ID/RIN_GITHUB_CLIENT_SECRET。" + }, + "ready": { + "impact": "後台登入可用。", + "summary": "OAuth 登入已配置,且目前密碼登入未使用預設憑證。", + "suggestion": "無需處理。" + }, + "details": { + "github_configured": "GitHub OAuth:已配置", + "github_missing": "GitHub OAuth:未配置", + "password_configured": "密碼登入:已配置", + "password_missing": "密碼登入:未配置", + "password_default": "密碼登入:使用預設憑證" + } }, "storage": { "title": "物件儲存", @@ -719,5 +720,67 @@ }, "sort_latest": "最新", "sort_popular": "熱門", - "total_tags": "共 {{count}} 個標籤" + "total_tags": "共 {{count}} 個標籤", + "api_keys": { + "title": "API Key", + "loading": "正在載入 API Key...", + "empty": "尚未建立任何 API Key。", + "copied": "已複製到剪貼簿。", + "never": "永不", + "revoke": "撤銷", + "create": { + "title": "建立代理 API Key", + "description": "為需要讀取或寫入 Rin 內容的外部代理產生 Bearer Token。", + "name_label": "名稱", + "name_placeholder": "Agent Writer", + "expiry_label": "到期時間", + "submit": "建立 Key", + "creating": "建立中..." + }, + "expiry": { + "never": "永不過期", + "30d": "30 天後到期", + "90d": "90 天後到期", + "7d": "7 天後到期", + "14d": "14 天後到期", + "180d": "180 天後到期", + "365d": "365 天後到期" + }, + "secret": { + "title": "請立即複製金鑰", + "description": "Rin 只會顯示完整 Key 一次。關閉前請先保存到你的代理工具中。", + "copy": "複製 Key", + "dismiss": "關閉" + }, + "list": { + "title": "現有 Key", + "description": "可在這裡查看 Key 狀態、最近使用時間與到期時間。" + }, + "status": { + "active": "啟用中", + "revoked": "已撤銷" + }, + "fields": { + "created_at": "建立時間:{{date}}", + "last_used_at": "最近使用:{{date}}", + "expires_at": "到期時間:{{date}}", + "capabilities": "能力範圍:{{scopes}}" + }, + "docs": { + "title": "代理接入說明", + "description": "提供給外部代理使用 Rin API Key 的內建說明。", + "scope_note": "這些 Key 面向內容工作流程,例如建立文章、更新草稿、發布想法與上傳媒體。", + "routes_note": "管理設定介面不會對 API Key 開放。設定與診斷仍需使用互動式管理員工作階段。", + "copy_curl": "複製 curl 範例", + "open_skill": "開啟內建 Skill" + }, + "revoke_confirm": { + "title": "撤銷 API Key", + "description": "確認撤銷 {{name}} 嗎?所有使用它的外部代理都會立即失效。" + }, + "errors": { + "name_required": "請填寫名稱。", + "create_failed": "建立 API Key 失敗。" + } + } } diff --git a/client/public/skills/rin-agent/SKILL.md b/client/public/skills/rin-agent/SKILL.md new file mode 100644 index 000000000..8bc94e9e2 --- /dev/null +++ b/client/public/skills/rin-agent/SKILL.md @@ -0,0 +1,79 @@ +--- +name: rin-agent +description: Read and update Rin blog content through a user-provided API key. Use when an external agent needs to list posts, read a post, create or update drafts, publish moments, or upload media to a Rin site. +--- + +# Rin Agent Access + +## Required Inputs + +- `RIN_BASE_URL`: the public base URL of the Rin site, for example `https://blog.example.com` +- `RIN_API_KEY`: an API key created from Rin admin at `/admin/api-keys` + +Send the API key as a bearer token on every request: + +```http +Authorization: Bearer +``` + +## Supported Content Workflows + +### List published posts + +`GET {{RIN_BASE_URL}}/api/feed` + +### Read a post + +`GET {{RIN_BASE_URL}}/api/feed/:id` + +`id` can be the numeric id or the post alias. + +### Create a post + +`POST {{RIN_BASE_URL}}/api/feed` + +```json +{ + "title": "Agent draft", + "content": "# Hello from Rin", + "summary": "Created by an external agent", + "draft": true, + "listed": false, + "tags": [] +} +``` + +### Update a post + +`POST {{RIN_BASE_URL}}/api/feed/:id` + +Send the same shape used for create, with the fields you want to change. + +### Delete a post + +`DELETE {{RIN_BASE_URL}}/api/feed/:id` + +### Create a moment + +`POST {{RIN_BASE_URL}}/api/moments` + +```json +{ + "content": "Short update from an external agent." +} +``` + +### Upload media + +`POST {{RIN_BASE_URL}}/api/storage` + +Use `multipart/form-data` with: + +- `file`: the uploaded file +- `key`: optional original filename + +## Constraints + +- These API keys are intended for content workflows, not site-wide settings changes. +- Treat the key as a secret. Rin only shows the full value once when the key is created. +- If a request returns `401` or `403`, ask the user to verify the key is still active in `/admin/api-keys`. diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 5a8c071db..f807795da 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -33,6 +33,10 @@ import type { AuthStatus, LoginRequest, LoginResponse, + ApiKeyListResponse, + CreateApiKeyRequest, + CreateApiKeyResponse, + RevokeApiKeyResponse, } from "@rin/api"; export interface SettingsConfigResponse { @@ -155,6 +159,10 @@ export type { AuthStatus, LoginRequest, LoginResponse, + ApiKeyListResponse, + CreateApiKeyRequest, + CreateApiKeyResponse, + RevokeApiKeyResponse, } from "@rin/api"; @@ -538,6 +546,22 @@ class ConfigAPI { } } +class ApiKeysAPI { + constructor(private http: HttpClient) {} + + async list(): Promise> { + return this.http.get("/api/api-keys"); + } + + async create(body: CreateApiKeyRequest): Promise> { + return this.http.post("/api/api-keys", body); + } + + async revoke(id: number): Promise> { + return this.http.post(`/api/api-keys/${id}/revoke`); + } +} + /** * AI Config API methods (deprecated, use ConfigAPI instead) * @deprecated AI config is now part of server config. Use client.config.get('server') and client.config.update('server', {...}) instead. @@ -658,6 +682,7 @@ export class ApiClient { friend: FriendAPI; moments: MomentsAPI; config: ConfigAPI; + apiKeys: ApiKeysAPI; aiConfig: AIConfigAPI; storage: StorageAPI; search: SearchAPI; @@ -674,6 +699,7 @@ export class ApiClient { this.friend = new FriendAPI(this.http); this.moments = new MomentsAPI(this.http); this.config = new ConfigAPI(this.http); + this.apiKeys = new ApiKeysAPI(this.http); this.aiConfig = new AIConfigAPI(this.http); this.storage = new StorageAPI(this.http); this.search = new SearchAPI(this.http); diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index cd8bc8a8f..110b808ff 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -24,6 +24,7 @@ import { MomentsPage } from "../page/moments"; import { ProfilePage } from "../page/profile"; import { QueueStatusPage } from "../page/queue-status"; import { SearchPage } from "../page/search"; +import { ApiKeysPage } from "../page/api-keys"; import { Settings } from "../page/settings"; import { TimelinePage } from "../page/timeline"; import { WritingPage } from "../page/writing"; @@ -68,6 +69,10 @@ export function AppRoutes() { + + + + diff --git a/client/src/components/admin-layout.tsx b/client/src/components/admin-layout.tsx index 9e885c92e..6beaa1d8f 100644 --- a/client/src/components/admin-layout.tsx +++ b/client/src/components/admin-layout.tsx @@ -64,6 +64,7 @@ export function AdminLayout({
+ diff --git a/client/src/page/api-keys.tsx b/client/src/page/api-keys.tsx new file mode 100644 index 000000000..b19539185 --- /dev/null +++ b/client/src/page/api-keys.tsx @@ -0,0 +1,303 @@ +import { SearchableSelect, SettingsBadge, SettingsCard, SettingsCardBody, SettingsCardHeader } from "@rin/ui"; +import type { ApiKeyRecord } from "@rin/api"; +import { useEffect, useMemo, useState } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import ReactLoading from "react-loading"; +import { client } from "../app/runtime"; +import { Button } from "../components/button"; +import { useAlert, useConfirm } from "../components/dialog"; +import { useSiteConfig } from "../hooks/useSiteConfig"; + +type ExpiryOption = "never" | "7d" | "14d" | "30d" | "90d" | "180d" | "365d"; + +const EXPIRY_OPTIONS: Array<{ value: ExpiryOption; labelKey: string }> = [ + { value: "never", labelKey: "api_keys.expiry.never" }, + { value: "7d", labelKey: "api_keys.expiry.7d" }, + { value: "14d", labelKey: "api_keys.expiry.14d" }, + { value: "30d", labelKey: "api_keys.expiry.30d" }, + { value: "90d", labelKey: "api_keys.expiry.90d" }, + { value: "180d", labelKey: "api_keys.expiry.180d" }, + { value: "365d", labelKey: "api_keys.expiry.365d" }, +]; + +function buildExpiryDate(option: ExpiryOption) { + if (option === "never") { + return null; + } + + const now = new Date(); + const days = option === "7d" ? 7 : option === "14d" ? 14 : option === "30d" ? 30 : option === "90d" ? 90 : option === "180d" ? 180 : 365; + now.setDate(now.getDate() + days); + return now.toISOString(); +} + +function formatDate(value: string | null, t: ReturnType["t"]) { + if (!value) { + return t("api_keys.never"); + } + + return new Date(value).toLocaleString(); +} + +export function ApiKeysPage() { + const { t } = useTranslation(); + const siteConfig = useSiteConfig(); + const { showAlert, AlertUI } = useAlert(); + const { showConfirm, ConfirmUI } = useConfirm(); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [items, setItems] = useState([]); + const [name, setName] = useState(""); + const [expiry, setExpiry] = useState("never"); + const [createdSecret, setCreatedSecret] = useState(null); + + const skillUrl = useMemo(() => { + if (typeof window === "undefined") { + return "/skills/rin-agent/SKILL.md"; + } + + return new URL("/skills/rin-agent/SKILL.md", window.location.origin).toString(); + }, []); + + const apiBaseUrl = useMemo(() => { + if (typeof window === "undefined") { + return ""; + } + + return window.location.origin; + }, []); + + async function loadApiKeys() { + setLoading(true); + try { + const { data, error } = await client.apiKeys.list(); + if (error) { + throw new Error(error.value); + } + setItems(data?.items ?? []); + } catch (error) { + showAlert(error instanceof Error ? error.message : String(error)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadApiKeys(); + }, []); + + async function handleCreate() { + if (!name.trim()) { + showAlert(t("api_keys.errors.name_required")); + return; + } + + setSubmitting(true); + try { + const { data, error } = await client.apiKeys.create({ + name: name.trim(), + expiresAt: buildExpiryDate(expiry), + }); + + if (error || !data) { + throw new Error(error?.value ?? t("api_keys.errors.create_failed")); + } + + setCreatedSecret(data.secret); + setItems((current) => [data.apiKey, ...current]); + setName(""); + setExpiry("never"); + } catch (error) { + showAlert(error instanceof Error ? error.message : String(error)); + } finally { + setSubmitting(false); + } + } + + async function copyText(value: string) { + try { + await navigator.clipboard.writeText(value); + showAlert(t("api_keys.copied")); + } catch (error) { + showAlert(error instanceof Error ? error.message : String(error)); + } + } + + async function handleRevoke(id: number) { + const { error } = await client.apiKeys.revoke(id); + if (error) { + throw new Error(error.value); + } + + setItems((current) => + current.map((item) => + item.id === id + ? { + ...item, + revokedAt: new Date().toISOString(), + } + : item, + ), + ); + } + + const exampleToken = createdSecret ?? "rin_your_api_key_here"; + const curlExample = [ + `curl -X POST '${apiBaseUrl}/api/feed' \\`, + ` -H 'Authorization: Bearer ${exampleToken}' \\`, + ` -H 'Content-Type: application/json' \\`, + ` -d '{"title":"Agent draft","content":"# Hello from Rin","summary":"Created by an external agent","draft":true,"listed":false,"tags":[]}'`, + ].join("\n"); + + return ( +
+ + {`${t("api_keys.title")} - ${siteConfig.name}`} + + + + + +
+ + +
+
+
+
+
+ + {createdSecret ? ( + + + +
+ {createdSecret} +
+
+
+
+
+ ) : null} + +
+
+ + + + {loading ? ( +
+ + {t("api_keys.loading")} +
+ ) : null} + + {!loading && items.length === 0 ?

{t("api_keys.empty")}

: null} + + {!loading && items.length > 0 ? ( +
+ {items.map((item) => ( + +
+
+ + {item.revokedAt ? t("api_keys.status.revoked") : t("api_keys.status.active")} + + } + /> +
+ {item.revokedAt ? null : ( +
+
+ )} +
+ +
+

{t("api_keys.fields.created_at", { date: formatDate(item.createdAt, t) })}

+

{t("api_keys.fields.last_used_at", { date: formatDate(item.lastUsedAt, t) })}

+

{t("api_keys.fields.expires_at", { date: formatDate(item.expiresAt, t) })}

+

{t("api_keys.fields.capabilities", { scopes: item.scopes.join(", ") })}

+
+
+
+ ))} +
+ ) : null} +
+
+
+ +
+ + + +
+

{t("api_keys.docs.scope_note")}

+

{t("api_keys.docs.routes_note")}

+
+                  {curlExample}
+                
+
+
+
+
+
+
+
+ + + +
+ ); +} diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 4a13f290a..fc83ca462 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -140,6 +140,44 @@ export interface LoginResponse { user: UserProfile; } +// ============================================================================ +// API Key Types +// ============================================================================ + +export interface ApiKeyRecord { + id: number; + name: string; + keyPrefix: string; + scopes: string[]; + createdAt: string; + updatedAt: string; + lastUsedAt: string | null; + expiresAt: string | null; + revokedAt: string | null; + createdByUser: { + id: number; + username: string; + } | null; +} + +export interface ApiKeyListResponse { + items: ApiKeyRecord[]; +} + +export interface CreateApiKeyRequest { + name: string; + expiresAt?: string | null; +} + +export interface CreateApiKeyResponse { + secret: string; + apiKey: ApiKeyRecord; +} + +export interface RevokeApiKeyResponse { + success: boolean; +} + // ============================================================================ // Tag Types // ============================================================================ diff --git a/server/sql/0010.sql b/server/sql/0010.sql new file mode 100644 index 000000000..f25f8cd7d --- /dev/null +++ b/server/sql/0010.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + key_prefix TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + scopes TEXT DEFAULT '[]' NOT NULL, + created_by_uid INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER, + revoked_at INTEGER, + created_at INTEGER DEFAULT (unixepoch()) NOT NULL, + updated_at INTEGER DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (created_by_uid) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/server/src/core/hono-middleware.ts b/server/src/core/hono-middleware.ts index 39bfa3ea1..00e0f72a1 100644 --- a/server/src/core/hono-middleware.ts +++ b/server/src/core/hono-middleware.ts @@ -3,7 +3,7 @@ import { createMiddleware } from "hono/factory"; import { getCookie, setCookie } from "hono/cookie"; import { drizzle } from "drizzle-orm/d1"; import type { AppContext, Variables, JWTUtils, OAuth2Utils } from "./hono-types"; -import { eq } from "drizzle-orm"; +import { and, eq, gt, isNull, or } from "drizzle-orm"; import { profileAsync } from "./server-timing"; // Lazy initialization container @@ -97,6 +97,8 @@ export const initContainerMiddleware = createMiddleware<{ c.set('jwt', jwt); c.set('oauth2', oauth2); c.set('admin', false); + c.set('authType', undefined); + c.set('apiKeyScopes', undefined); c.set('env', c.env); }); @@ -112,16 +114,19 @@ export const authMiddleware = createMiddleware<{ const jwt = c.get('jwt'); const db = c.get('db'); - const token = await profileAsync(c, "auth_token", () => { + const bearerToken = await profileAsync(c, "auth_token", () => { const authHeader = c.req.header('authorization'); if (authHeader && authHeader.startsWith('Bearer ')) { return authHeader.substring(7); } + return undefined; + }); + const cookieToken = await profileAsync(c, "auth_cookie_token", () => { return getCookie(c, 'token'); }); - if (token && jwt) { - const profile = await profileAsync(c, "auth_verify", () => jwt.verify(token)); + if (bearerToken && jwt) { + const profile = await profileAsync(c, "auth_verify", () => jwt.verify(bearerToken)); if (profile) { const { users } = await import("../db/schema"); const user = await profileAsync(c, "auth_user_lookup", () => db.query.users.findFirst({ @@ -132,11 +137,72 @@ export const authMiddleware = createMiddleware<{ c.set('uid', user.id); c.set('username', user.username); c.set('admin', user.permission === 1); + c.set('authType', 'jwt'); + return; + } + } + + const { apiKeys } = await import("../db/schema"); + const { hashApiKey, parseApiKeyScopes } = await import("../utils/api-keys"); + const now = new Date(); + const keyHash = await profileAsync(c, "auth_api_key_hash", () => hashApiKey(bearerToken)); + const apiKey = await profileAsync(c, "auth_api_key_lookup", () => db.query.apiKeys.findFirst({ + where: and( + eq(apiKeys.keyHash, keyHash), + isNull(apiKeys.revokedAt), + or(isNull(apiKeys.expiresAt), gt(apiKeys.expiresAt, now)), + ), + with: { + createdByUser: true, + }, + })); + + if (apiKey?.createdByUser) { + await profileAsync(c, "auth_api_key_touch", () => db.update(apiKeys) + .set({ lastUsedAt: now, updatedAt: now }) + .where(eq(apiKeys.id, apiKey.id))); + + const scopes = parseApiKeyScopes(apiKey.scopes); + c.set('uid', apiKey.createdByUser.id); + c.set('username', apiKey.createdByUser.username); + c.set('admin', apiKey.createdByUser.permission === 1); + c.set('authType', 'api-key'); + c.set('apiKeyScopes', scopes); + return; + } + } + + if (cookieToken && jwt) { + const profile = await profileAsync(c, "auth_cookie_verify", () => jwt.verify(cookieToken)); + if (profile) { + const { users } = await import("../db/schema"); + const user = await profileAsync(c, "auth_cookie_user_lookup", () => db.query.users.findFirst({ + where: eq(users.id, profile.id) + })); + + if (user) { + c.set('uid', user.id); + c.set('username', user.username); + c.set('admin', user.permission === 1); + c.set('authType', 'jwt'); } } } }); + if (c.get("authType") === "api-key") { + const { getRequiredApiKeyScope, hasApiKeyScope } = await import("../utils/api-keys"); + const requiredScope = getRequiredApiKeyScope(c.req.method, c.req.path); + + if (!requiredScope) { + return c.text("API key does not have access to this route", 403); + } + + if (!hasApiKeyScope(c.get("apiKeyScopes"), requiredScope)) { + return c.text("API key does not have the required scope", 403); + } + } + await next(); }); diff --git a/server/src/core/hono-types.ts b/server/src/core/hono-types.ts index 497c2f621..fa958e382 100644 --- a/server/src/core/hono-types.ts +++ b/server/src/core/hono-types.ts @@ -38,6 +38,8 @@ export interface Variables { uid?: number; admin: boolean; username?: string; + authType?: "jwt" | "api-key"; + apiKeyScopes?: string[]; env: Env; } diff --git a/server/src/core/register-routes.ts b/server/src/core/register-routes.ts index 7f609948f..6bd847bdc 100644 --- a/server/src/core/register-routes.ts +++ b/server/src/core/register-routes.ts @@ -1,4 +1,5 @@ import type { RinApp } from "./app-types"; +import { ApiKeyService } from "../services/api-keys"; import { PasswordAuthService } from "../services/auth"; import { CommentService } from "../services/comments"; import { ConfigService } from "../services/config"; @@ -25,6 +26,7 @@ export function registerRoutes(app: RinApp) { app.route("/moments", MomentsService()); app.route("/user", UserService()); app.route("/auth", PasswordAuthService()); + app.route("/api-keys", ApiKeyService()); app.route("/config", ConfigService()); app.route("/", RSSService()); app.route("/favicon", FaviconService()); diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 24569c83e..e5de56955 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -73,6 +73,20 @@ export const users = sqliteTable("users", { updatedAt: updated_at, }); +export const apiKeys = sqliteTable("api_keys", { + id: integer("id").primaryKey(), + name: text("name").notNull(), + keyPrefix: text("key_prefix").notNull(), + keyHash: text("key_hash").notNull().unique(), + scopes: text("scopes").default("[]").notNull(), + createdByUid: integer("created_by_uid").references(() => users.id, { onDelete: 'cascade' }).notNull(), + lastUsedAt: integer("last_used_at", { mode: 'timestamp' }), + expiresAt: integer("expires_at", { mode: 'timestamp' }), + revokedAt: integer("revoked_at", { mode: 'timestamp' }), + createdAt: created_at, + updatedAt: updated_at, +}); + export const comments = sqliteTable("comments", { id: integer("id").primaryKey(), feedId: integer("feed_id").references(() => feeds.id, { onDelete: 'cascade' }).notNull(), @@ -135,6 +149,13 @@ export const commentsRelations = relations(comments, ({ one }) => ({ }), })); +export const apiKeysRelations = relations(apiKeys, ({ one }) => ({ + createdByUser: one(users, { + fields: [apiKeys.createdByUid], + references: [users.id], + }), +})); + export const hashtagsRelations = relations(hashtags, ({ many }) => ({ feeds: many(feedHashtags), })); diff --git a/server/src/services/__tests__/api-keys.test.ts b/server/src/services/__tests__/api-keys.test.ts new file mode 100644 index 000000000..c7c135f10 --- /dev/null +++ b/server/src/services/__tests__/api-keys.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { Database } from "bun:sqlite"; +import { Hono } from "hono"; +import { createMiddleware } from "hono/factory"; +import { authMiddleware } from "../../core/hono-middleware"; +import type { Variables } from "../../core/hono-types"; +import type { ApiKeyListResponse, CreateApiKeyResponse } from "@rin/api"; +import { FeedService } from "../feed"; +import { ApiKeyService } from "../api-keys"; +import { ConfigService } from "../config"; +import { UserService } from "../user"; +import { hashApiKey } from "../../utils/api-keys"; +import { + TestCacheImpl, + cleanupTestDB, + createMockDB, + createMockEnv, +} from "../../../tests/fixtures"; + +describe("ApiKeyService", () => { + let db: any; + let sqlite: Database; + let env: Env; + let app: Hono<{ Bindings: Env; Variables: Variables }>; + + beforeEach(() => { + const mockDB = createMockDB(); + db = mockDB.db; + sqlite = mockDB.sqlite; + env = createMockEnv({ + ADMIN_USERNAME: "admin", + ADMIN_PASSWORD: "admin123", + }); + + sqlite.exec(` + INSERT INTO users (id, username, avatar, openid, password, permission) + VALUES (1, 'admin', '', 'admin', 'hashed', 1) + `); + + app = new Hono<{ Bindings: Env; Variables: Variables }>(); + app.use(createMiddleware(async (c, next) => { + c.set("db", db); + c.set("env", env); + c.set("admin", true); + c.set("uid", 1); + c.set("username", "admin"); + c.set("authType", "jwt"); + await next(); + })); + app.route("/api-keys", ApiKeyService()); + }); + + afterEach(() => { + cleanupTestDB(sqlite); + }); + + it("creates, lists, and revokes an API key", async () => { + const createResponse = await app.request("/api-keys", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + name: "Agent Writer", + }), + }, env); + expect(createResponse.status).toBe(200); + const created = await createResponse.json() as CreateApiKeyResponse; + expect(created.secret.startsWith("rin_")).toBe(true); + expect(created.apiKey.name).toBe("Agent Writer"); + expect(created.apiKey.scopes).toContain("content:write"); + + const listResponse = await app.request("/api-keys", { method: "GET" }, env); + expect(listResponse.status).toBe(200); + const listed = await listResponse.json() as ApiKeyListResponse; + expect(listed.items).toHaveLength(1); + expect(listed.items[0].keyPrefix).toBe(created.apiKey.keyPrefix); + expect(listed.items[0].revokedAt).toBeNull(); + + const revokeResponse = await app.request(`/api-keys/${created.apiKey.id}/revoke`, { + method: "POST", + }, env); + expect(revokeResponse.status).toBe(200); + + const afterRevokeResponse = await app.request("/api-keys", { method: "GET" }, env); + const afterRevoke = await afterRevokeResponse.json() as ApiKeyListResponse; + expect(afterRevoke.items[0].revokedAt).not.toBeNull(); + }); +}); + +describe("API key auth middleware", () => { + let db: any; + let sqlite: Database; + let env: Env; + let app: Hono<{ Bindings: Env; Variables: Variables }>; + + beforeEach(async () => { + const mockDB = createMockDB(); + db = mockDB.db; + sqlite = mockDB.sqlite; + env = createMockEnv({ + ADMIN_USERNAME: "admin", + ADMIN_PASSWORD: "admin123", + }); + + sqlite.exec(` + INSERT INTO users (id, username, avatar, openid, password, permission) + VALUES (1, 'admin', '', 'admin', 'hashed', 1) + `); + + const secret = "rin_test_agent_key"; + sqlite.exec(` + INSERT INTO api_keys (id, name, key_prefix, key_hash, scopes, created_by_uid) + VALUES ( + 1, + 'Agent Writer', + 'rin_test_age', + '${await hashApiKey(secret)}', + '["content:read","content:write","moments:write","media:write"]', + 1 + ) + `); + + const cache = new TestCacheImpl(); + const clientConfig = new TestCacheImpl(); + const serverConfig = new TestCacheImpl(); + + app = new Hono<{ Bindings: Env; Variables: Variables }>(); + app.use(createMiddleware(async (c, next) => { + c.set("db", db); + c.set("env", env); + c.set("cache", cache); + c.set("clientConfig", clientConfig); + c.set("serverConfig", serverConfig); + c.set("jwt", { + sign: async (payload: any) => `mock_token_${payload.id}`, + verify: async (token: string) => { + const match = token.match(/mock_token_(\d+)/); + return match ? { id: Number(match[1]) } : null; + }, + }); + c.set("admin", false); + await next(); + })); + app.use("*", authMiddleware); + app.route("/feed", FeedService()); + app.route("/config", ConfigService()); + app.route("/user", UserService()); + }); + + afterEach(() => { + cleanupTestDB(sqlite); + }); + + it("allows documented content routes with a bearer API key and blocks other routes", async () => { + const createFeedResponse = await app.request("/feed", { + method: "POST", + headers: { + authorization: "Bearer rin_test_agent_key", + "content-type": "application/json", + }, + body: JSON.stringify({ + title: "Agent-created post", + content: "Hello from an external agent.", + summary: "Summary", + alias: "agent-created-post", + draft: false, + listed: true, + tags: [], + }), + }, env); + + expect(createFeedResponse.status).toBe(200); + const createdFeed = await createFeedResponse.json() as { insertedId?: number }; + expect(createdFeed.insertedId).toBeDefined(); + + const configResponse = await app.request("/config", { + method: "GET", + headers: { + authorization: "Bearer rin_test_agent_key", + }, + }, env); + expect(configResponse.status).toBe(403); + + const userProfileResponse = await app.request("/user/profile", { + method: "GET", + headers: { + authorization: "Bearer rin_test_agent_key", + }, + }, env); + expect(userProfileResponse.status).toBe(403); + + const keyRecord = sqlite.prepare("SELECT last_used_at FROM api_keys WHERE id = 1").get() as { last_used_at: number | null }; + expect(keyRecord.last_used_at).not.toBeNull(); + }); + + it("enforces stored API key scopes on allowed routes", async () => { + const readOnlySecret = "rin_test_read_only_key"; + sqlite.exec(` + INSERT INTO api_keys (id, name, key_prefix, key_hash, scopes, created_by_uid) + VALUES ( + 2, + 'Read Only Agent', + 'rin_test_rea', + '${await hashApiKey(readOnlySecret)}', + '["content:read"]', + 1 + ) + `); + + const createFeedResponse = await app.request("/feed", { + method: "POST", + headers: { + authorization: `Bearer ${readOnlySecret}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + title: "Read-only should fail", + content: "Blocked write", + summary: "Blocked write", + alias: "read-only-should-fail", + draft: true, + listed: false, + tags: [], + }), + }, env); + + expect(createFeedResponse.status).toBe(403); + }); +}); diff --git a/server/src/services/api-keys.ts b/server/src/services/api-keys.ts new file mode 100644 index 000000000..2bcd007f5 --- /dev/null +++ b/server/src/services/api-keys.ts @@ -0,0 +1,156 @@ +import { desc, eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { AppContext } from "../core/hono-types"; +import { apiKeys } from "../db/schema"; +import { profileAsync } from "../core/server-timing"; +import { generateApiKeySecret, getApiKeyPrefix, hashApiKey, parseApiKeyScopes } from "../utils/api-keys"; + +const DEFAULT_AGENT_SCOPES = [ + "content:read", + "content:write", + "moments:write", + "media:write", +]; + +function requireInteractiveAdmin(c: AppContext) { + if (!c.get("admin")) { + return c.text("Unauthorized", 401); + } + + if (c.get("authType") === "api-key") { + return c.text("API keys cannot manage API keys", 403); + } + + return null; +} + +function serializeApiKeyRecord(record: typeof apiKeys.$inferSelect & { + createdByUser?: { + id: number; + username: string; + } | null; +}) { + return { + id: record.id, + name: record.name, + keyPrefix: record.keyPrefix, + scopes: parseApiKeyScopes(record.scopes), + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + lastUsedAt: record.lastUsedAt?.toISOString() ?? null, + expiresAt: record.expiresAt?.toISOString() ?? null, + revokedAt: record.revokedAt?.toISOString() ?? null, + createdByUser: record.createdByUser + ? { + id: record.createdByUser.id, + username: record.createdByUser.username, + } + : null, + }; +} + +export function ApiKeyService(): Hono { + const app = new Hono(); + + app.get("/", async (c: AppContext) => { + const unauthorized = requireInteractiveAdmin(c); + if (unauthorized) { + return unauthorized; + } + + const db = c.get("db"); + const items = await profileAsync(c, "api_keys_list", () => db.query.apiKeys.findMany({ + with: { + createdByUser: { + columns: { + id: true, + username: true, + }, + }, + }, + orderBy: [desc(apiKeys.createdAt)], + })); + + return c.json({ + items: items.map((item) => serializeApiKeyRecord(item)), + }); + }); + + app.post("/", async (c: AppContext) => { + const unauthorized = requireInteractiveAdmin(c); + if (unauthorized) { + return unauthorized; + } + + const db = c.get("db"); + const uid = c.get("uid"); + const body = await profileAsync(c, "api_keys_create_parse", () => c.req.json()) as { + name?: string; + expiresAt?: string | null; + }; + const name = body.name?.trim(); + + if (!uid) { + return c.text("Unauthorized", 401); + } + + if (!name) { + return c.text("Name is required", 400); + } + + const expiresAt = body.expiresAt ? new Date(body.expiresAt) : null; + if (expiresAt && Number.isNaN(expiresAt.getTime())) { + return c.text("Invalid expiration date", 400); + } + + const secret = generateApiKeySecret(); + const now = new Date(); + const result = await profileAsync(c, "api_keys_create_insert", async () => { + return db.insert(apiKeys).values({ + name, + keyPrefix: getApiKeyPrefix(secret), + keyHash: await hashApiKey(secret), + scopes: JSON.stringify(DEFAULT_AGENT_SCOPES), + createdByUid: uid, + expiresAt: expiresAt ?? undefined, + createdAt: now, + updatedAt: now, + }).returning(); + }); + + const created = result[0]; + if (!created) { + return c.text("Failed to create API key", 500); + } + + return c.json({ + secret, + apiKey: serializeApiKeyRecord(created), + }); + }); + + app.post("/:id/revoke", async (c: AppContext) => { + const unauthorized = requireInteractiveAdmin(c); + if (unauthorized) { + return unauthorized; + } + + const db = c.get("db"); + const id = Number(c.req.param("id")); + if (!Number.isInteger(id) || id <= 0) { + return c.text("Invalid API key id", 400); + } + + const now = new Date(); + await profileAsync(c, "api_keys_revoke", () => db.update(apiKeys) + .set({ + revokedAt: now, + updatedAt: now, + }) + .where(eq(apiKeys.id, id))); + + return c.json({ success: true }); + }); + + return app; +} diff --git a/server/src/utils/api-keys.ts b/server/src/utils/api-keys.ts new file mode 100644 index 000000000..7215c637d --- /dev/null +++ b/server/src/utils/api-keys.ts @@ -0,0 +1,64 @@ +function bytesToHex(buffer: ArrayBuffer) { + return Array.from(new Uint8Array(buffer)) + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} + +type ApiKeyRouteRule = { + method: string; + pattern: RegExp; + scope: string; +}; + +const API_KEY_ROUTE_RULES: ApiKeyRouteRule[] = [ + { method: "GET", pattern: /^\/feed(?:\/timeline)?$/, scope: "content:read" }, + { method: "GET", pattern: /^\/feed\/[^/]+$/, scope: "content:read" }, + { method: "POST", pattern: /^\/feed$/, scope: "content:write" }, + { method: "POST", pattern: /^\/feed\/[^/]+$/, scope: "content:write" }, + { method: "DELETE", pattern: /^\/feed\/[^/]+$/, scope: "content:write" }, + { method: "POST", pattern: /^\/moments$/, scope: "moments:write" }, + { method: "POST", pattern: /^\/storage$/, scope: "media:write" }, +]; + +export async function hashApiKey(secret: string): Promise { + const encoder = new TextEncoder(); + return bytesToHex(await crypto.subtle.digest("SHA-256", encoder.encode(secret))); +} + +export function generateApiKeySecret() { + const bytes = crypto.getRandomValues(new Uint8Array(24)); + return `rin_${bytesToHex(bytes.buffer)}`; +} + +export function getApiKeyPrefix(secret: string) { + return secret.slice(0, 12); +} + +export function parseApiKeyScopes(value: string | null | undefined) { + if (!value) { + return []; + } + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((scope): scope is string => typeof scope === "string") : []; + } catch { + return []; + } +} + +export function getRequiredApiKeyScope(method: string, path: string) { + const normalizedMethod = method.toUpperCase(); + + for (const rule of API_KEY_ROUTE_RULES) { + if (rule.method === normalizedMethod && rule.pattern.test(path)) { + return rule.scope; + } + } + + return null; +} + +export function hasApiKeyScope(scopes: string[] | undefined, requiredScope: string) { + return Array.isArray(scopes) && scopes.includes(requiredScope); +} diff --git a/server/tests/fixtures/index.ts b/server/tests/fixtures/index.ts index 784c09780..091f5f664 100644 --- a/server/tests/fixtures/index.ts +++ b/server/tests/fixtures/index.ts @@ -36,6 +36,21 @@ export function createMockDB() { updated_at INTEGER DEFAULT (unixepoch()) ); + CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + key_prefix TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + scopes TEXT DEFAULT '[]' NOT NULL, + created_by_uid INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER, + revoked_at INTEGER, + created_at INTEGER DEFAULT (unixepoch()), + updated_at INTEGER DEFAULT (unixepoch()), + FOREIGN KEY (created_by_uid) REFERENCES users(id) ON DELETE CASCADE + ); + -- Feeds table CREATE TABLE IF NOT EXISTS feeds ( id INTEGER PRIMARY KEY AUTOINCREMENT,