feat(api): add public RESTful API with API key auth and documentation page
This commit is contained in:
306
app/templates/api_docs.html
Normal file
306
app/templates/api_docs.html
Normal file
@@ -0,0 +1,306 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'api_docs.title'|t(user.language) }} - {{ 'brand'|t(user.language) }}{% endblock %}
|
||||
{% block content %}
|
||||
<style>
|
||||
.doc-section { margin-bottom:2rem; }
|
||||
.doc-section h2 { font-size:1.15rem; margin-bottom:.75rem; padding-bottom:.5rem; border-bottom:1px solid var(--border); }
|
||||
.doc-section h3 { font-size:1rem; margin:1rem 0 .5rem; }
|
||||
.doc-endpoint { background:var(--card-bg); border:1px solid var(--border); border-radius:8px; padding:1rem 1.25rem; margin-bottom:1rem; }
|
||||
.doc-endpoint .ep-header { display:flex; align-items:center; gap:.6rem; margin-bottom:.5rem; flex-wrap:wrap; }
|
||||
.doc-method { display:inline-block; padding:2px 8px; border-radius:4px; font-size:.75rem; font-weight:700; color:#fff; white-space:nowrap; }
|
||||
.method-get { background:#22c55e; }
|
||||
.method-post { background:#3b82f6; }
|
||||
.method-put { background:#f59e0b; }
|
||||
.method-delete { background:#ef4444; }
|
||||
.doc-path { font-family:monospace; font-size:.9rem; color:var(--text); word-break:break-all; }
|
||||
.doc-desc { color:var(--text-muted); font-size:.85rem; margin-bottom:.5rem; }
|
||||
.doc-table { width:100%; border-collapse:collapse; font-size:.82rem; margin-top:.5rem; }
|
||||
.doc-table th, .doc-table td { text-align:left; padding:4px 8px; border-bottom:1px solid var(--border); }
|
||||
.doc-table th { color:var(--text-muted); font-weight:600; white-space:nowrap; }
|
||||
.doc-table td:first-child { font-family:monospace; white-space:nowrap; }
|
||||
.doc-note { color:var(--text-muted); font-size:.82rem; margin-top:.25rem; }
|
||||
.doc-json { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:.5rem .75rem; font-family:monospace; font-size:.8rem; margin-top:.5rem; overflow-x:auto; white-space:pre; }
|
||||
.doc-auth-box { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:1rem; margin:.75rem 0; }
|
||||
.doc-auth-box code { background:var(--card-bg); padding:2px 6px; border-radius:3px; font-size:.82rem; }
|
||||
.try-box { margin-top:.5rem; display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
|
||||
.try-box input { flex:1; min-width:200px; font-family:monospace; font-size:.82rem; }
|
||||
.try-box button { flex-shrink:0; }
|
||||
.try-result { margin-top:.5rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:.5rem .75rem; font-family:monospace; font-size:.78rem; max-height:300px; overflow:auto; white-space:pre-wrap; word-break:break-all; display:none; }
|
||||
.try-result.show { display:block; }
|
||||
.tag-required { color:var(--danger); font-size:.75rem; }
|
||||
</style>
|
||||
|
||||
<div class="section-header">
|
||||
<h1>{{ 'api_docs.title'|t(user.language) }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="doc-section">
|
||||
<p style="color:var(--text-muted);font-size:.9rem">{{ 'api_docs.intro'|t(user.language) }}</p>
|
||||
<p style="margin-top:.5rem"><a href="/settings?tab=apikeys" class="btn btn-sm" style="display:inline-block">{{ 'api_docs.go_settings'|t(user.language) }}</a></p>
|
||||
</div>
|
||||
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.base_url'|t(user.language) }}</h2>
|
||||
<div class="doc-json">{{ request.base_url }}api/v1</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.auth'|t(user.language) }}</h2>
|
||||
<p class="doc-desc">{{ 'api_docs.auth_desc'|t(user.language) }}</p>
|
||||
<div class="doc-auth-box">
|
||||
<code>X-API-Key: mv_your_api_key_here</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Profiles ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.profile_ops'|t(user.language) }}</h2>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/profiles</span>
|
||||
</div>
|
||||
<div class="doc-desc">获取当前用户的所有 Profile 列表。</div>
|
||||
<div class="doc-json">[
|
||||
{"id": 1, "nickname": "Japan", "showcase_enabled": false, "showcase_visible": "both", "notes": "", "created_at": "2025-01-01T00:00:00"},
|
||||
...
|
||||
]</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-post">POST</span>
|
||||
<span class="doc-path">/profiles</span>
|
||||
</div>
|
||||
<div class="doc-desc">创建新 Profile。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>nickname</td><td>string <span class="tag-required">{{ 'misc.required'|t(user.language) }}</span></td></tr>
|
||||
</table>
|
||||
<div class="doc-json">{"id": 2, "nickname": "Europe", ...}</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/profiles/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">获取单个 Profile 详情。</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-put">PUT</span>
|
||||
<span class="doc-path">/profiles/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">更新 Profile。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>nickname</td><td>string(可选)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-delete">DELETE</span>
|
||||
<span class="doc-path">/profiles/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">删除 Profile 及其下所有明信片。</div>
|
||||
<div class="doc-json">{"ok": true}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Postcards ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.postcard_ops'|t(user.language) }}</h2>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards</span>
|
||||
</div>
|
||||
<div class="doc-desc">获取指定 Profile 下的明信片列表。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.params'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>status</td><td>string(可选)— 按状态筛选:sent / delivered / received</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-post">POST</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards</span>
|
||||
</div>
|
||||
<div class="doc-desc">创建新明信片。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>card_number</td><td>string <span class="tag-required">* {{ 'misc.required'|t(user.language) }}</span></td></tr>
|
||||
<tr><td>status</td><td>string <span class="tag-required">* {{ 'misc.required'|t(user.language) }}</span> — sent / delivered / received</td></tr>
|
||||
<tr><td>recipient_name</td><td>string(可选)</td></tr>
|
||||
<tr><td>country</td><td>string(可选)— 两个大写字母,如 JP</td></tr>
|
||||
<tr><td>send_time</td><td>string(可选)— 格式 YYYY-MM-DD 或 YYYY-MM-DDTHH:MM</td></tr>
|
||||
<tr><td>arrival_time</td><td>string(可选)</td></tr>
|
||||
<tr><td>sender_name</td><td>string(可选)</td></tr>
|
||||
<tr><td>receive_time</td><td>string(可选)</td></tr>
|
||||
<tr><td>notes</td><td>string(可选)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">获取单张明信片详情。</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-put">PUT</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">更新明信片。仅传入需要修改的字段。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>card_number</td><td>string(可选)</td></tr>
|
||||
<tr><td>status</td><td>string(可选)</td></tr>
|
||||
<tr><td>recipient_name</td><td>string(可选)</td></tr>
|
||||
<tr><td>country</td><td>string(可选)</td></tr>
|
||||
<tr><td>send_time</td><td>string(可选)</td></tr>
|
||||
<tr><td>arrival_time</td><td>string(可选)</td></tr>
|
||||
<tr><td>sender_name</td><td>string(可选)</td></tr>
|
||||
<tr><td>receive_time</td><td>string(可选)</td></tr>
|
||||
<tr><td>notes</td><td>string(可选)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-delete">DELETE</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">删除单张明信片。</div>
|
||||
<div class="doc-json">{"ok": true}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Image Upload ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.upload_op'|t(user.language) }}</h2>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-post">POST</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards/{id}/upload</span>
|
||||
</div>
|
||||
<div class="doc-desc">上传明信片图片(正面或背面),使用 <code>multipart/form-data</code>。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>Form Field</th><th></th></tr>
|
||||
<tr><td>file</td><td>File <span class="tag-required">* {{ 'misc.required'|t(user.language) }}</span> — 图片文件</td></tr>
|
||||
<tr><td>side</td><td>string(可选)— front 或 back,默认 front</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ API Keys ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.key_ops'|t(user.language) }}</h2>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/keys</span>
|
||||
</div>
|
||||
<div class="doc-desc">获取当前用户的所有 API Key。</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-post">POST</span>
|
||||
<span class="doc-path">/keys</span>
|
||||
</div>
|
||||
<div class="doc-desc">创建新的 API Key。创建后完整 Key 仅在响应中显示一次。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>name</td><td>string(可选)— Key 的备注名称</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-delete">DELETE</span>
|
||||
<span class="doc-path">/keys/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">删除指定 API Key。</div>
|
||||
<div class="doc-json">{"ok": true}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Showcase ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.showcase_ops'|t(user.language) }}</h2>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/showcase/{username}</span>
|
||||
</div>
|
||||
<div class="doc-desc">{{ 'api_docs.no_auth'|t(user.language) }} — 获取用户的公开展示数据。</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/showcase/{username}/profile/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">{{ 'api_docs.no_auth'|t(user.language) }} — 获取单个 Profile 的展示数据。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Try It ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.try_it'|t(user.language) }}</h2>
|
||||
<div class="try-box">
|
||||
<input type="text" id="try-key" placeholder="{{ 'api_docs.api_key_ph'|t(user.language) }}">
|
||||
<select id="try-method" style="width:auto;flex-shrink:0">
|
||||
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option>
|
||||
</select>
|
||||
<input type="text" id="try-url" placeholder="/profiles" style="flex:1;min-width:150px;font-family:monospace">
|
||||
<button class="btn btn-primary btn-sm" onclick="tryApi()">{{ 'api_docs.send'|t(user.language) }}</button>
|
||||
</div>
|
||||
<textarea id="try-body" rows="3" placeholder="Request body (JSON, for POST/PUT)" style="width:100%;margin-top:.5rem;font-family:monospace;font-size:.82rem;resize:vertical;display:none"></textarea>
|
||||
<div class="try-result" id="try-result"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('try-method').addEventListener('change', function() {
|
||||
document.getElementById('try-body').style.display = (this.value === 'POST' || this.value === 'PUT') ? '' : 'none';
|
||||
});
|
||||
|
||||
async function tryApi() {
|
||||
var key = document.getElementById('try-key').value.trim();
|
||||
var method = document.getElementById('try-method').value;
|
||||
var path = document.getElementById('try-url').value.trim();
|
||||
var body = document.getElementById('try-body').value.trim();
|
||||
var result = document.getElementById('try-result');
|
||||
result.className = 'try-result show';
|
||||
result.textContent = 'Loading...';
|
||||
try {
|
||||
var baseUrl = '{{ request.base_url }}api/v1';
|
||||
var opts = { method: method, headers: {} };
|
||||
if (key) opts.headers['X-API-Key'] = key;
|
||||
if (body && (method === 'POST' || method === 'PUT')) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = body;
|
||||
}
|
||||
var resp = await fetch(baseUrl + path, opts);
|
||||
var text = await resp.text();
|
||||
try { text = JSON.stringify(JSON.parse(text), null, 2); } catch(e) {}
|
||||
result.textContent = resp.status + ' ' + resp.statusText + '\n\n' + text;
|
||||
} catch(e) {
|
||||
result.textContent = 'Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -17,6 +17,7 @@
|
||||
{% if user.is_admin %}
|
||||
<a href="/admin/invites">{{ 'nav.invites'|t(user.language) }}</a>
|
||||
{% endif %}
|
||||
<a href="/api/docs">{{ 'nav.api_docs'|t(user.language) }}</a>
|
||||
<form method="post" action="/settings/language" class="inline" style="display:inline-flex;align-items:center;gap:0">
|
||||
<input type="hidden" name="language" value="{{ 'en' if user.language == 'zh' else 'zh' }}">
|
||||
<button type="submit" class="nav-lang-btn">{{ 'EN' if user.language == 'zh' else '中' }}</button>
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
</div>
|
||||
|
||||
<div class="tabs" id="tabs">
|
||||
<button class="tab {% if active_tab != 'showcase' %}active{% endif %}" onclick="switchTab('account')">{{ 'settings.tab_account'|t(user.language) }}</button>
|
||||
<button class="tab {% if active_tab != 'showcase' and active_tab != 'apikeys' %}active{% endif %}" onclick="switchTab('account')">{{ 'settings.tab_account'|t(user.language) }}</button>
|
||||
<button class="tab {% if active_tab == 'showcase' %}active{% endif %}" onclick="switchTab('showcase')">{{ 'settings.tab_showcase'|t(user.language) }}</button>
|
||||
<button class="tab {% if active_tab == 'apikeys' %}active{% endif %}" onclick="switchTab('apikeys')">{{ 'settings.tab_apikeys'|t(user.language) }}</button>
|
||||
</div>
|
||||
|
||||
{% if success %}
|
||||
@@ -17,7 +18,7 @@
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="tab-account" class="tab-panel" {% if active_tab == 'showcase' %}style="display:none"{% endif %}>
|
||||
<div id="tab-account" class="tab-panel" {% if active_tab == 'showcase' or active_tab == 'apikeys' %}style="display:none"{% endif %}>
|
||||
<div class="form-card">
|
||||
<h3 style="margin-bottom:.75rem">{{ 'settings.change_username'|t(user.language) }}</h3>
|
||||
<form method="post" action="/settings/change-username">
|
||||
@@ -155,6 +156,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-apikeys" class="tab-panel" {% if active_tab != 'apikeys' %}style="display:none"{% endif %}>
|
||||
<div class="form-card">
|
||||
<h3 style="margin-bottom:.75rem">{{ 'apikey.title'|t(user.language) }}</h3>
|
||||
<p style="color:var(--text-muted);font-size:.85rem;margin-bottom:1rem">{{ 'apikey.hint'|t(user.language) }}</p>
|
||||
|
||||
{% if new_key %}
|
||||
<div class="alert alert-success" style="margin-bottom:1rem">
|
||||
<strong>{{ 'apikey.created'|t(user.language) }}</strong><br>
|
||||
<code style="word-break:break-all;font-size:.85rem">{{ new_key }}</code><br>
|
||||
<small style="color:var(--text-muted)">{{ 'apikey.copy_now'|t(user.language) }}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings/apikeys/create" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem">
|
||||
<input type="text" name="name" placeholder="{{ 'apikey.name_ph'|t(user.language) }}" style="max-width:200px">
|
||||
<button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.create'|t(user.language) }}</button>
|
||||
</form>
|
||||
|
||||
{% if api_keys %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'apikey.name'|t(user.language) }}</th>
|
||||
<th>{{ 'apikey.key'|t(user.language) }}</th>
|
||||
<th>{{ 'apikey.created_at'|t(user.language) }}</th>
|
||||
<th>{{ 'apikey.last_used'|t(user.language) }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ak in api_keys %}
|
||||
<tr>
|
||||
<td>{{ ak.name or '—' }}</td>
|
||||
<td><code style="font-size:.8rem">{{ ak.key[:8] }}****</code></td>
|
||||
<td>{{ ak.created_at|to_local|strftime('%Y-%m-%d') }}</td>
|
||||
<td>{% if ak.last_used_at %}{{ ak.last_used_at|to_local|strftime('%Y-%m-%d') }}{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" action="/settings/apikeys/{{ ak.id }}/delete" style="display:inline" onsubmit="return confirm('{{ 'apikey.confirm_delete'|t(user.language) }}')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">{{ 'apikey.delete'|t(user.language) }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>{{ 'apikey.empty'|t(user.language) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
||||
|
||||
Reference in New Issue
Block a user