feat(admin): add session-based portal login
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
SUB2API_CRM_LISTEN_ADDR=:8080
|
||||
SUB2API_CRM_SQLITE_DSN=file:/data/sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000
|
||||
SUB2API_CRM_ADMIN_TOKEN=change-me-before-production
|
||||
SUB2API_CRM_ADMIN_USERNAME=admin
|
||||
SUB2API_CRM_ADMIN_PASSWORD=change-me-before-production
|
||||
SUB2API_CRM_ADMIN_SESSION_TTL=12h
|
||||
|
||||
@@ -412,7 +412,7 @@
|
||||
<article class="panel">
|
||||
<h2>发起导入</h2>
|
||||
<p class="panel-desc">
|
||||
用 admin token 直接调用当前控制面的 batch-import API。
|
||||
优先使用管理员登录会话调用当前控制面的 batch-import API;必要时也可以回退到 Bearer token。
|
||||
`entries` 每行一个供应商帐号:`base_url|api_key|model_a,model_b`。
|
||||
</p>
|
||||
|
||||
@@ -426,8 +426,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>Admin Token
|
||||
<label>Admin Token(可选)
|
||||
<input id="admin-token" type="password" placeholder="secret-token">
|
||||
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
|
||||
</label>
|
||||
<label>Mode
|
||||
<select id="mode">
|
||||
@@ -437,6 +438,21 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>管理员用户名
|
||||
<input id="admin-username" type="text" placeholder="admin">
|
||||
</label>
|
||||
<label>管理员密码
|
||||
<input id="admin-password" type="password" placeholder="请输入管理员密码">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button id="admin-login-btn" type="button">管理员登录</button>
|
||||
<button id="admin-logout-btn" type="button" class="ghost">退出登录</button>
|
||||
<span id="admin-session-status" class="statusbar">尚未检查管理员会话。</span>
|
||||
</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>Access Mode
|
||||
<select id="access-mode">
|
||||
@@ -568,6 +584,11 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
const apiBaseInput = document.getElementById("api-base");
|
||||
const hostIDInput = document.getElementById("host-id");
|
||||
const adminTokenInput = document.getElementById("admin-token");
|
||||
const adminUsernameInput = document.getElementById("admin-username");
|
||||
const adminPasswordInput = document.getElementById("admin-password");
|
||||
const adminLoginButton = document.getElementById("admin-login-btn");
|
||||
const adminLogoutButton = document.getElementById("admin-logout-btn");
|
||||
const adminSessionStatus = document.getElementById("admin-session-status");
|
||||
const modeInput = document.getElementById("mode");
|
||||
const accessModeInput = document.getElementById("access-mode");
|
||||
const confirmTimeoutInput = document.getElementById("confirm-timeout");
|
||||
@@ -615,6 +636,7 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
apiBase: apiBaseInput.value.trim(),
|
||||
hostID: hostIDInput.value.trim(),
|
||||
adminToken: adminTokenInput.value,
|
||||
adminUsername: adminUsernameInput.value.trim(),
|
||||
mode: modeInput.value,
|
||||
accessMode: accessModeInput.value,
|
||||
confirmTimeoutSec: confirmTimeoutInput.value,
|
||||
@@ -641,6 +663,7 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
apiBaseInput.value = payload.apiBase || defaultApiBase();
|
||||
hostIDInput.value = payload.hostID || "";
|
||||
adminTokenInput.value = payload.adminToken || "";
|
||||
adminUsernameInput.value = payload.adminUsername || "";
|
||||
modeInput.value = payload.mode || "strict";
|
||||
accessModeInput.value = payload.accessMode || "self_service";
|
||||
confirmTimeoutInput.value = payload.confirmTimeoutSec || "10";
|
||||
@@ -673,14 +696,14 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = adminTokenInput.value.trim();
|
||||
if (!token) {
|
||||
throw new Error("admin token 不能为空");
|
||||
}
|
||||
return {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
};
|
||||
const token = adminTokenInput.value.trim();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function parseEntries() {
|
||||
@@ -734,7 +757,16 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
}
|
||||
|
||||
async function requestJSON(path, options = {}) {
|
||||
const response = await fetch(`${normalizeApiBase()}${path}`, options);
|
||||
const { skipAuth = false, headers = {}, ...rest } = options;
|
||||
const finalHeaders = { ...headers };
|
||||
if (!skipAuth) {
|
||||
Object.assign(finalHeaders, authHeaders(), finalHeaders);
|
||||
}
|
||||
const response = await fetch(`${normalizeApiBase()}${path}`, {
|
||||
...rest,
|
||||
credentials: "include",
|
||||
headers: finalHeaders,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = {};
|
||||
try {
|
||||
@@ -749,6 +781,58 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function refreshAdminSession() {
|
||||
try {
|
||||
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
|
||||
if (payload.username && !adminUsernameInput.value.trim()) {
|
||||
adminUsernameInput.value = payload.username;
|
||||
}
|
||||
if (payload.authenticated) {
|
||||
setStatus(`管理员已登录:${payload.username}`, "success");
|
||||
adminSessionStatus.textContent = `已登录:${payload.username}`;
|
||||
} else if (payload.login_enabled) {
|
||||
adminSessionStatus.textContent = "未登录,可直接使用管理员用户名密码建立会话。";
|
||||
} else {
|
||||
adminSessionStatus.textContent = "当前实例未启用管理员密码登录,只能使用 Bearer token。";
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
adminSessionStatus.textContent = `管理员会话检查失败:${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAdminSession() {
|
||||
const username = adminUsernameInput.value.trim();
|
||||
const password = adminPasswordInput.value;
|
||||
if (!username || !password) {
|
||||
throw new Error("管理员用户名和密码不能为空");
|
||||
}
|
||||
const payload = await requestJSON("/api/admin/session/login", {
|
||||
method: "POST",
|
||||
skipAuth: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
adminPasswordInput.value = "";
|
||||
saveConfig();
|
||||
adminSessionStatus.textContent = `已登录:${payload.username}`;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function logoutAdminSession() {
|
||||
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
adminPasswordInput.value = "";
|
||||
adminSessionStatus.textContent = "管理员会话已退出。";
|
||||
}
|
||||
|
||||
function syncHeaderMetrics() {
|
||||
metricApiRoot.textContent = normalizeApiBase();
|
||||
metricRunID.textContent = state.currentRunID || "-";
|
||||
@@ -926,11 +1010,28 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
document.getElementById("load-sample-btn").addEventListener("click", loadSampleEntries);
|
||||
document.getElementById("clear-items-btn").addEventListener("click", clearResults);
|
||||
document.getElementById("apply-filter-btn").addEventListener("click", refreshRun);
|
||||
adminLoginButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await loginAdminSession();
|
||||
setStatus("管理员会话已建立。", "success");
|
||||
} catch (error) {
|
||||
setStatus(error.message, "danger");
|
||||
}
|
||||
});
|
||||
adminLogoutButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await logoutAdminSession();
|
||||
await refreshAdminSession();
|
||||
} catch (error) {
|
||||
setStatus(error.message, "danger");
|
||||
}
|
||||
});
|
||||
accessModeInput.addEventListener("change", updateAccessModeFields);
|
||||
|
||||
restoreConfig();
|
||||
updateAccessModeFields();
|
||||
syncHeaderMetrics();
|
||||
refreshAdminSession().catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -375,7 +375,7 @@
|
||||
</p>
|
||||
<ul class="hero-points">
|
||||
<li>默认 API Base:<code>/portal-admin-api</code></li>
|
||||
<li>支持同域 Bearer admin token</li>
|
||||
<li>支持管理员登录会话,也保留 Bearer admin token 兜底</li>
|
||||
<li>支持 provider 草稿发布到 pack 仓库</li>
|
||||
</ul>
|
||||
</article>
|
||||
@@ -408,11 +408,27 @@
|
||||
<label>API Base
|
||||
<input id="api-base" type="text" placeholder="https://sub.tksea.top/portal-admin-api">
|
||||
</label>
|
||||
<label>Admin Token
|
||||
<label>Admin Token(可选)
|
||||
<input id="admin-token" type="password" placeholder="crm-admin-token">
|
||||
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>管理员用户名
|
||||
<input id="admin-username" type="text" placeholder="admin">
|
||||
</label>
|
||||
<label>管理员密码
|
||||
<input id="admin-password" type="password" placeholder="请输入管理员密码">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="admin-login-btn" type="button">管理员登录</button>
|
||||
<button id="admin-logout-btn" type="button" class="ghost">退出登录</button>
|
||||
<span id="admin-session-status" class="status">尚未检查管理员会话。</span>
|
||||
</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>Pack
|
||||
<select id="pack-id"></select>
|
||||
@@ -592,6 +608,11 @@
|
||||
|
||||
const apiBaseInput = document.getElementById("api-base");
|
||||
const adminTokenInput = document.getElementById("admin-token");
|
||||
const adminUsernameInput = document.getElementById("admin-username");
|
||||
const adminPasswordInput = document.getElementById("admin-password");
|
||||
const adminLoginButton = document.getElementById("admin-login-btn");
|
||||
const adminLogoutButton = document.getElementById("admin-logout-btn");
|
||||
const adminSessionStatus = document.getElementById("admin-session-status");
|
||||
const packIDInput = document.getElementById("pack-id");
|
||||
const hostIDInput = document.getElementById("host-id");
|
||||
const packPathInput = document.getElementById("pack-path");
|
||||
@@ -641,18 +662,27 @@
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = adminTokenInput.value.trim();
|
||||
if (!token) {
|
||||
throw new Error("admin token 不能为空");
|
||||
}
|
||||
return {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
};
|
||||
const token = adminTokenInput.value.trim();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function requestJSON(path, options = {}) {
|
||||
return fetch(`${normalizeApiBase()}${path}`, options)
|
||||
const { skipAuth = false, headers = {}, ...rest } = options;
|
||||
const finalHeaders = { ...headers };
|
||||
if (!skipAuth) {
|
||||
Object.assign(finalHeaders, authHeaders(), finalHeaders);
|
||||
}
|
||||
return fetch(`${normalizeApiBase()}${path}`, {
|
||||
...rest,
|
||||
credentials: "include",
|
||||
headers: finalHeaders,
|
||||
})
|
||||
.then(async (response) => {
|
||||
const text = await response.text();
|
||||
let payload = {};
|
||||
@@ -679,6 +709,7 @@
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
apiBase: apiBaseInput.value.trim(),
|
||||
adminToken: adminTokenInput.value,
|
||||
adminUsername: adminUsernameInput.value.trim(),
|
||||
packID: packIDInput.value,
|
||||
hostID: hostIDInput.value,
|
||||
packPath: packPathInput.value.trim(),
|
||||
@@ -714,6 +745,7 @@
|
||||
const payload = JSON.parse(raw);
|
||||
apiBaseInput.value = payload.apiBase || defaultApiBase();
|
||||
adminTokenInput.value = payload.adminToken || "";
|
||||
adminUsernameInput.value = payload.adminUsername || "";
|
||||
packPathInput.value = payload.packPath || "/app/packs/openai-cn-pack";
|
||||
providerIDInput.value = payload.providerID || "";
|
||||
modeInput.value = payload.mode || "strict";
|
||||
@@ -739,6 +771,57 @@
|
||||
subscriptionFields.hidden = accessModeInput.value !== "subscription";
|
||||
}
|
||||
|
||||
async function refreshAdminSession() {
|
||||
try {
|
||||
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
|
||||
if (payload.username && !adminUsernameInput.value.trim()) {
|
||||
adminUsernameInput.value = payload.username;
|
||||
}
|
||||
if (payload.authenticated) {
|
||||
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
|
||||
} else if (payload.login_enabled) {
|
||||
setStatus(adminSessionStatus, "未登录。可直接使用管理员用户名密码建立会话。", "note");
|
||||
} else {
|
||||
setStatus(adminSessionStatus, "当前实例未启用管理员密码登录,只能使用 Bearer token。", "warning");
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
setStatus(adminSessionStatus, `管理员会话检查失败:${error.message}`, "danger");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAdminSession() {
|
||||
const username = adminUsernameInput.value.trim();
|
||||
const password = adminPasswordInput.value;
|
||||
if (!username || !password) {
|
||||
throw new Error("管理员用户名和密码不能为空");
|
||||
}
|
||||
const payload = await requestJSON("/api/admin/session/login", {
|
||||
method: "POST",
|
||||
skipAuth: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
adminPasswordInput.value = "";
|
||||
saveConfig();
|
||||
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function logoutAdminSession() {
|
||||
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
adminPasswordInput.value = "";
|
||||
setStatus(adminSessionStatus, "管理员会话已退出。", "note");
|
||||
}
|
||||
|
||||
function renderSelectOptions(select, values, currentValue, emptyLabel) {
|
||||
select.innerHTML = "";
|
||||
if (!values.length) {
|
||||
@@ -1203,6 +1286,22 @@
|
||||
document.getElementById("delete-draft-btn").addEventListener("click", deleteDraftOnServer);
|
||||
document.getElementById("copy-draft-btn").addEventListener("click", copyDraft);
|
||||
document.getElementById("refresh-drafts-btn").addEventListener("click", loadServerDrafts);
|
||||
adminLoginButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await loginAdminSession();
|
||||
await loadCatalog();
|
||||
} catch (error) {
|
||||
setStatus(adminSessionStatus, error.message, "danger");
|
||||
}
|
||||
});
|
||||
adminLogoutButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await logoutAdminSession();
|
||||
await refreshAdminSession();
|
||||
} catch (error) {
|
||||
setStatus(adminSessionStatus, error.message, "danger");
|
||||
}
|
||||
});
|
||||
accessModeInput.addEventListener("change", updateAccessModeFields);
|
||||
packIDInput.addEventListener("change", loadCatalog);
|
||||
providerIDInput.addEventListener("input", syncMetrics);
|
||||
@@ -1211,6 +1310,7 @@
|
||||
restoreConfig();
|
||||
updateAccessModeFields();
|
||||
syncMetrics();
|
||||
refreshAdminSession().catch(() => {});
|
||||
renderServerDrafts();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -62,6 +62,7 @@ SUB2API_CRM_ADMIN_TOKEN=change-me-before-production SUB2API_CRM_LISTEN_ADDR=127.
|
||||
- `https://sub.tksea.top/portal/admin/`
|
||||
- 管理首页
|
||||
- 统一提供“新增模型 / 供应商目录”和“导入供应商帐号”入口
|
||||
- 当前已支持管理员用户名 / 密码登录;登录成功后浏览器会持有同域 HttpOnly session cookie
|
||||
- `https://sub.tksea.top/portal/admin/providers.html`
|
||||
- provider 目录与 preview/import 管理页
|
||||
- 当前已支持通过 `provider_drafts` API 把 provider manifest 草稿持久化到 CRM SQLite,并直接更新 / 删除 / 发布到 pack 仓库
|
||||
@@ -77,9 +78,23 @@ SUB2API_CRM_ADMIN_TOKEN=change-me-before-production SUB2API_CRM_LISTEN_ADDR=127.
|
||||
|
||||
- `https://sub.tksea.top/portal-admin-api/`
|
||||
- 反代到 CRM
|
||||
- 浏览器侧仍需 Bearer admin token
|
||||
- 浏览器侧优先走管理员 session;同时保留 Bearer admin token 兼容脚本与紧急兜底
|
||||
- 作用是让静态 admin 页面不必直接访问 remote43 的内网 `18173`
|
||||
|
||||
管理员登录配置:
|
||||
|
||||
- `SUB2API_CRM_ADMIN_TOKEN`
|
||||
- 必填
|
||||
- 继续作为服务端管理 API 的 Bearer token,同时也是 session cookie 的签名密钥
|
||||
- `SUB2API_CRM_ADMIN_USERNAME`
|
||||
- 可选,默认 `admin`
|
||||
- `SUB2API_CRM_ADMIN_PASSWORD`
|
||||
- 可选
|
||||
- 若未配置,当前实现会回退为“使用 `SUB2API_CRM_ADMIN_TOKEN` 作为登录密码”
|
||||
- `SUB2API_CRM_ADMIN_SESSION_TTL`
|
||||
- 可选,默认 `12h`
|
||||
- 控制浏览器管理态 session 的有效期
|
||||
|
||||
当前 provider 草稿发布相关 API:
|
||||
|
||||
- `POST /api/provider-drafts`
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
- `https://sub.tksea.top/portal/admin/providers.html`:provider 目录 / preview-import / import / manifest 草稿页
|
||||
- `https://sub.tksea.top/portal/admin/batch-import.html`:结构化 batch-import 入口,当前跳转到 legacy `admin-batch-import.html`
|
||||
- Nginx 示例与 deploy 脚本已补同域 CRM 反代 `https://sub.tksea.top/portal-admin-api/`
|
||||
- 目的不是绕过鉴权,而是让浏览器可直接操作 remote43 CRM,同时继续由 Bearer admin token 控制权限
|
||||
- 目的不是绕过鉴权,而是让浏览器可直接操作 remote43 CRM;当前已继续补成“管理员用户名 / 密码登录 + HttpOnly session cookie”,同时保留 Bearer admin token 兼容脚本与紧急兜底
|
||||
- 2026-05-27 已继续把 provider manifest 草稿从“只存在浏览器”补成真正的服务端能力:
|
||||
- 新增 `POST /api/provider-drafts`
|
||||
- 新增 `GET /api/provider-drafts`
|
||||
@@ -67,6 +67,18 @@
|
||||
- `GET /portal/` 返回 `200`
|
||||
- `GET /kimi-portal/` 返回 `302 -> /portal/`
|
||||
- `GET /portal-proxy/api/v1/keys` 在无效 token 下已命中宿主真实 `INVALID_TOKEN`,说明新的同域代理已生效
|
||||
- 2026-05-28 已继续把管理态“每次手贴 Bearer token”收口为正式登录流:
|
||||
- 新增 `GET /api/admin/session`
|
||||
- 新增 `POST /api/admin/session/login`
|
||||
- 新增 `POST /api/admin/session/logout`
|
||||
- 管理态受保护接口现已同时接受:
|
||||
- `Authorization: Bearer <SUB2API_CRM_ADMIN_TOKEN>`
|
||||
- 或同域管理员 session cookie
|
||||
- `providers.html` 与 `admin-batch-import.html` 现已优先走 session,token 输入框仅保留为兜底
|
||||
- 当前部署环境可通过以下变量显式配置管理员账号:
|
||||
- `SUB2API_CRM_ADMIN_USERNAME`
|
||||
- `SUB2API_CRM_ADMIN_PASSWORD`
|
||||
- `SUB2API_CRM_ADMIN_SESSION_TTL`
|
||||
- 2026-05-26 已把“最终用户 -> 公网域名 -> OpenClaw”这一跳补进正式验证口径:
|
||||
- 公网根地址当前统一为 `https://sub.tksea.top`
|
||||
- OpenClaw 本地 `MiniMax` 运行时故障已定位为 `pi-ai/openai-node` 未继承系统 `HTTP(S)_PROXY`,不是 allowlist 或模型名大小写问题
|
||||
|
||||
278
internal/app/admin_auth.go
Normal file
278
internal/app/admin_auth.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
adminSessionCookieName = "sub2api_crm_admin_session"
|
||||
defaultAdminUsername = "admin"
|
||||
defaultAdminSessionTTL = 12 * time.Hour
|
||||
)
|
||||
|
||||
type AdminAuthConfig struct {
|
||||
Token string
|
||||
Username string
|
||||
Password string
|
||||
SessionTTL time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type adminLoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type adminSessionInfo struct {
|
||||
Username string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (c AdminAuthConfig) normalized() AdminAuthConfig {
|
||||
c.Token = strings.TrimSpace(c.Token)
|
||||
c.Username = strings.TrimSpace(c.Username)
|
||||
c.Password = strings.TrimSpace(c.Password)
|
||||
if c.Username == "" {
|
||||
c.Username = defaultAdminUsername
|
||||
}
|
||||
if c.Password == "" {
|
||||
c.Password = c.Token
|
||||
}
|
||||
if c.SessionTTL <= 0 {
|
||||
c.SessionTTL = defaultAdminSessionTTL
|
||||
}
|
||||
if c.Now == nil {
|
||||
c.Now = time.Now
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c AdminAuthConfig) loginEnabled() bool {
|
||||
cfg := c.normalized()
|
||||
return cfg.Token != "" && cfg.Password != ""
|
||||
}
|
||||
|
||||
func (c AdminAuthConfig) now() time.Time {
|
||||
return c.normalized().Now()
|
||||
}
|
||||
|
||||
func (c AdminAuthConfig) sessionCookie(r *http.Request) (*http.Cookie, *adminSessionInfo, bool) {
|
||||
cfg := c.normalized()
|
||||
cookie, err := r.Cookie(adminSessionCookieName)
|
||||
if err != nil || cookie == nil || strings.TrimSpace(cookie.Value) == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
info, ok := verifyAdminSessionValue(cfg.Token, cookie.Value, cfg.now())
|
||||
if !ok {
|
||||
return cookie, nil, false
|
||||
}
|
||||
return cookie, info, true
|
||||
}
|
||||
|
||||
func (c AdminAuthConfig) requestAuthorized(r *http.Request) bool {
|
||||
cfg := c.normalized()
|
||||
if secureCompare(bearerToken(r), cfg.Token) {
|
||||
return true
|
||||
}
|
||||
_, _, ok := cfg.sessionCookie(r)
|
||||
return ok
|
||||
}
|
||||
|
||||
func secureCompare(left, right string) bool {
|
||||
if left == "" || right == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
|
||||
}
|
||||
|
||||
func signAdminSessionValue(secret, username string, expiresAt time.Time) string {
|
||||
payload := strings.TrimSpace(username) + "|" + strconv.FormatInt(expiresAt.Unix(), 10)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + signature
|
||||
}
|
||||
|
||||
func verifyAdminSessionValue(secret, raw string, now time.Time) (*adminSessionInfo, bool) {
|
||||
if strings.TrimSpace(secret) == "" || strings.TrimSpace(raw) == "" {
|
||||
return nil, false
|
||||
}
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) != 2 {
|
||||
return nil, false
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
payload := string(payloadBytes)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
expectedSignature := mac.Sum(nil)
|
||||
signatureBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if subtle.ConstantTimeCompare(signatureBytes, expectedSignature) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
fields := strings.Split(payload, "|")
|
||||
if len(fields) != 2 {
|
||||
return nil, false
|
||||
}
|
||||
username := strings.TrimSpace(fields[0])
|
||||
if username == "" {
|
||||
return nil, false
|
||||
}
|
||||
unixSeconds, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
expiresAt := time.Unix(unixSeconds, 0)
|
||||
if !expiresAt.After(now) {
|
||||
return nil, false
|
||||
}
|
||||
return &adminSessionInfo{Username: username, ExpiresAt: expiresAt}, true
|
||||
}
|
||||
|
||||
func issueAdminSessionCookie(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig, username string) *adminSessionInfo {
|
||||
cfg = cfg.normalized()
|
||||
expiresAt := cfg.now().Add(cfg.SessionTTL)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminSessionCookieName,
|
||||
Value: signAdminSessionValue(cfg.Token, username, expiresAt),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: requestUsesHTTPS(r),
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(cfg.SessionTTL.Seconds()),
|
||||
})
|
||||
return &adminSessionInfo{Username: username, ExpiresAt: expiresAt}
|
||||
}
|
||||
|
||||
func clearAdminSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminSessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: requestUsesHTTPS(r),
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func requestUsesHTTPS(r *http.Request) bool {
|
||||
if r != nil && r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
|
||||
}
|
||||
|
||||
func handleAdminSessionState(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig) {
|
||||
cfg = cfg.normalized()
|
||||
payload := map[string]any{
|
||||
"authenticated": false,
|
||||
"login_enabled": cfg.loginEnabled(),
|
||||
"username": cfg.Username,
|
||||
}
|
||||
if _, session, ok := cfg.sessionCookie(r); ok {
|
||||
payload["authenticated"] = true
|
||||
payload["username"] = session.Username
|
||||
payload["expires_at"] = session.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func handleAdminSessionLogin(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig) {
|
||||
cfg = cfg.normalized()
|
||||
if cfg.Token == "" {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"})
|
||||
return
|
||||
}
|
||||
if !cfg.loginEnabled() {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusServiceUnavailable, Code: "login_disabled", Message: "admin login is not enabled"})
|
||||
return
|
||||
}
|
||||
var req adminLoginRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "username and password are required"})
|
||||
return
|
||||
}
|
||||
if !secureCompare(strings.TrimSpace(req.Username), cfg.Username) || !secureCompare(strings.TrimSpace(req.Password), cfg.Password) {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "invalid admin credentials"})
|
||||
return
|
||||
}
|
||||
session := issueAdminSessionCookie(w, r, cfg, cfg.Username)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"authenticated": true,
|
||||
"username": session.Username,
|
||||
"expires_at": session.ExpiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func handleAdminSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||||
clearAdminSessionCookie(w, r)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func requireAdminAccess(cfg AdminAuthConfig, next http.Handler) http.Handler {
|
||||
cfg = cfg.normalized()
|
||||
if cfg.Token == "" {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"})
|
||||
})
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !cfg.requestAuthorized(r) {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "missing or invalid admin credentials"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func adminSessionDebugValue(secret, username string, expiresAt time.Time) string {
|
||||
return hex.EncodeToString([]byte(signAdminSessionValue(secret, username, expiresAt)))
|
||||
}
|
||||
|
||||
func adminSessionPayload(raw string) map[string]any {
|
||||
payload := map[string]any{"raw": raw}
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) != 2 {
|
||||
return payload
|
||||
}
|
||||
body, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return payload
|
||||
}
|
||||
fields := strings.Split(string(body), "|")
|
||||
payload["payload"] = string(body)
|
||||
if len(fields) == 2 {
|
||||
payload["username"] = fields[0]
|
||||
payload["expires_unix"] = fields[1]
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func marshalAdminSessionPayload(raw string) string {
|
||||
body, _ := json.Marshal(adminSessionPayload(raw))
|
||||
return string(body)
|
||||
}
|
||||
@@ -128,6 +128,122 @@ func TestAPIRejectsMissingAdminToken(t *testing.T) {
|
||||
assertJSONContains(t, response.Body().Bytes(), "error.code", "unauthorized")
|
||||
}
|
||||
|
||||
func TestAPIAdminSessionLoginSetsCookieAndAuthorizesSubsequentRequest(t *testing.T) {
|
||||
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
|
||||
Token: "secret-token",
|
||||
Username: "admin",
|
||||
Password: "pass-123",
|
||||
SessionTTL: 2 * time.Hour,
|
||||
Now: func() time.Time {
|
||||
return time.Unix(1_717_000_000, 0)
|
||||
},
|
||||
}, ActionSet{
|
||||
ListPacks: func(context.Context) ([]PackInfo, error) {
|
||||
return []PackInfo{{PackID: "openai-cn-pack", Version: "1.1.6"}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
loginRequest := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
|
||||
"username": "admin",
|
||||
"password": "pass-123",
|
||||
}, "")
|
||||
loginResponse := httptestRecorder(handler, loginRequest)
|
||||
assertStatusCode(t, loginResponse, http.StatusOK)
|
||||
assertJSONContains(t, loginResponse.Body().Bytes(), "authenticated", true)
|
||||
assertJSONContains(t, loginResponse.Body().Bytes(), "username", "admin")
|
||||
|
||||
cookies := loginResponse.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("login cookies = %d, want 1", len(cookies))
|
||||
}
|
||||
if cookies[0].Name != adminSessionCookieName {
|
||||
t.Fatalf("cookie name = %q, want %q", cookies[0].Name, adminSessionCookieName)
|
||||
}
|
||||
if !cookies[0].HttpOnly {
|
||||
t.Fatal("session cookie HttpOnly = false, want true")
|
||||
}
|
||||
|
||||
authorizedRequest := httptestRequest(t, http.MethodGet, "/api/packs", nil, "")
|
||||
authorizedRequest.AddCookie(cookies[0])
|
||||
authorizedResponse := httptestRecorder(handler, authorizedRequest)
|
||||
assertStatusCode(t, authorizedResponse, http.StatusOK)
|
||||
if !strings.Contains(authorizedResponse.Body().String(), `"pack_id":"openai-cn-pack"`) {
|
||||
t.Fatalf("authorized response = %s, want pack_id openai-cn-pack", authorizedResponse.Body().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAdminSessionRejectsInvalidPassword(t *testing.T) {
|
||||
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
|
||||
Token: "secret-token",
|
||||
Username: "admin",
|
||||
Password: "pass-123",
|
||||
}, ActionSet{})
|
||||
request := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
|
||||
"username": "admin",
|
||||
"password": "wrong",
|
||||
}, "")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusUnauthorized)
|
||||
assertJSONContains(t, response.Body().Bytes(), "error.code", "unauthorized")
|
||||
}
|
||||
|
||||
func TestAPIAdminSessionLogoutClearsCookie(t *testing.T) {
|
||||
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
|
||||
Token: "secret-token",
|
||||
Username: "admin",
|
||||
Password: "pass-123",
|
||||
}, ActionSet{})
|
||||
request := httptestRequest(t, http.MethodPost, "/api/admin/session/logout", nil, "")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusNoContent)
|
||||
|
||||
cookies := response.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("logout cookies = %d, want 1", len(cookies))
|
||||
}
|
||||
if cookies[0].Name != adminSessionCookieName {
|
||||
t.Fatalf("cookie name = %q, want %q", cookies[0].Name, adminSessionCookieName)
|
||||
}
|
||||
if cookies[0].MaxAge != -1 {
|
||||
t.Fatalf("cookie MaxAge = %d, want -1", cookies[0].MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAdminSessionMeReportsAuthenticationState(t *testing.T) {
|
||||
now := time.Unix(1_717_000_000, 0)
|
||||
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
|
||||
Token: "secret-token",
|
||||
Username: "admin",
|
||||
Password: "pass-123",
|
||||
SessionTTL: time.Hour,
|
||||
Now: func() time.Time {
|
||||
return now
|
||||
},
|
||||
}, ActionSet{})
|
||||
|
||||
request := httptestRequest(t, http.MethodGet, "/api/admin/session", nil, "")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
assertJSONContains(t, response.Body().Bytes(), "authenticated", false)
|
||||
assertJSONContains(t, response.Body().Bytes(), "login_enabled", true)
|
||||
|
||||
loginRequest := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
|
||||
"username": "admin",
|
||||
"password": "pass-123",
|
||||
}, "")
|
||||
loginResponse := httptestRecorder(handler, loginRequest)
|
||||
assertStatusCode(t, loginResponse, http.StatusOK)
|
||||
|
||||
meRequest := httptestRequest(t, http.MethodGet, "/api/admin/session", nil, "")
|
||||
for _, cookie := range loginResponse.Result().Cookies() {
|
||||
meRequest.AddCookie(cookie)
|
||||
}
|
||||
meResponse := httptestRecorder(handler, meRequest)
|
||||
assertStatusCode(t, meResponse, http.StatusOK)
|
||||
assertJSONContains(t, meResponse.Body().Bytes(), "authenticated", true)
|
||||
assertJSONContains(t, meResponse.Body().Bytes(), "username", "admin")
|
||||
}
|
||||
|
||||
func TestAPIInstallPackReturnsSummary(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
InstallPack: func(context.Context, InstallPackRequest) (provision.PackInstallResult, error) {
|
||||
@@ -545,10 +661,26 @@ type responseRecorder struct {
|
||||
code int
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Header() http.Header { return r.header }
|
||||
func (r *responseRecorder) Write(body []byte) (int, error) { return r.body.Write(body) }
|
||||
func (r *responseRecorder) WriteHeader(statusCode int) { r.code = statusCode }
|
||||
func (r *responseRecorder) Body() *bytes.Buffer { return &r.body }
|
||||
func (r *responseRecorder) Header() http.Header { return r.header }
|
||||
func (r *responseRecorder) Write(body []byte) (int, error) {
|
||||
if r.code == 0 {
|
||||
r.code = http.StatusOK
|
||||
}
|
||||
return r.body.Write(body)
|
||||
}
|
||||
func (r *responseRecorder) WriteHeader(statusCode int) { r.code = statusCode }
|
||||
func (r *responseRecorder) Body() *bytes.Buffer { return &r.body }
|
||||
func (r *responseRecorder) Result() *http.Response {
|
||||
statusCode := r.code
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: statusCode,
|
||||
Header: r.header.Clone(),
|
||||
Body: io.NopCloser(bytes.NewReader(r.body.Bytes())),
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatusCode(t *testing.T, recorder *responseRecorder, want int) {
|
||||
t.Helper()
|
||||
|
||||
@@ -16,8 +16,17 @@ func Bootstrap(ctx context.Context) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adminSession, err := config.LoadAdminSessionFromEnv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startBackgroundSchedulers(ctx, cfg, defaultBackgroundSchedulers())
|
||||
handler := NewAPIHandler(adminToken, NewActionSet(cfg.Database.SQLiteDSN))
|
||||
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
|
||||
Token: adminToken,
|
||||
Username: adminSession.Username,
|
||||
Password: adminSession.Password,
|
||||
SessionTTL: adminSession.SessionTTL,
|
||||
}, NewActionSet(cfg.Database.SQLiteDSN))
|
||||
return NewServer(cfg.Server.ListenAddr, handler, nil), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -276,102 +276,115 @@ func (e *httpError) Error() string {
|
||||
}
|
||||
|
||||
func NewAPIHandler(adminToken string, actions ActionSet) http.Handler {
|
||||
return NewAPIHandlerWithAuth(AdminAuthConfig{Token: adminToken}, actions)
|
||||
}
|
||||
|
||||
func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", healthz)
|
||||
mux.Handle("POST /api/batch-import/runs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("GET /api/admin/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleAdminSessionState(w, r, adminAuth)
|
||||
})
|
||||
mux.HandleFunc("POST /api/admin/session/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleAdminSessionLogin(w, r, adminAuth)
|
||||
})
|
||||
mux.HandleFunc("POST /api/admin/session/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleAdminSessionLogout(w, r)
|
||||
})
|
||||
mux.Handle("POST /api/batch-import/runs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleCreateBatchImportRun(w, r, actions.CreateBatchImportRun)
|
||||
})))
|
||||
mux.Handle("GET /api/batch-import/runs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/batch-import/runs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListBatchImportRuns(w, r, actions.ListBatchImportRuns)
|
||||
})))
|
||||
mux.Handle("GET /api/batch-import/runs/{run_id}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/batch-import/runs/{run_id}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetBatchImportRun(w, r, actions.GetBatchImportRun)
|
||||
})))
|
||||
mux.Handle("GET /api/batch-import/runs/{run_id}/items", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/batch-import/runs/{run_id}/items", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListBatchImportRunItems(w, r, actions.ListBatchImportRunItems)
|
||||
})))
|
||||
mux.Handle("GET /api/batch-import/runs/{run_id}/items/{item_id}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/batch-import/runs/{run_id}/items/{item_id}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetBatchImportRunItem(w, r, actions.GetBatchImportRunItem)
|
||||
})))
|
||||
mux.Handle("POST /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/provider-drafts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleCreateProviderDraft(w, r, actions.CreateProviderDraft)
|
||||
})))
|
||||
mux.Handle("GET /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/provider-drafts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListProviderDrafts(w, r, actions.ListProviderDrafts)
|
||||
})))
|
||||
mux.Handle("GET /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/provider-drafts/{draftID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetProviderDraft(w, r, actions.GetProviderDraft)
|
||||
})))
|
||||
mux.Handle("PUT /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("PUT /api/provider-drafts/{draftID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleUpdateProviderDraft(w, r, actions.UpdateProviderDraft)
|
||||
})))
|
||||
mux.Handle("DELETE /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("DELETE /api/provider-drafts/{draftID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleDeleteProviderDraft(w, r, actions.DeleteProviderDraft)
|
||||
})))
|
||||
mux.Handle("POST /api/provider-drafts/{draftID}/publish", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/provider-drafts/{draftID}/publish", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlePublishProviderDraft(w, r, actions.PublishProviderDraft)
|
||||
})))
|
||||
mux.Handle("GET /api/import-batches/{batchID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/import-batches/{batchID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleBatchDetail(w, r, actions.BatchDetail)
|
||||
})))
|
||||
mux.Handle("POST /api/import-batches/{batchID}/rollback", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/import-batches/{batchID}/rollback", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRollbackBatch(w, r, actions.RollbackBatch)
|
||||
})))
|
||||
mux.Handle("GET /api/providers/{providerID}/status", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/providers/{providerID}/status", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleProviderStatus(w, r, actions.GetProviderStatus)
|
||||
})))
|
||||
mux.Handle("GET /api/providers/{providerID}/resources", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/providers/{providerID}/resources", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleProviderResources(w, r, actions.GetProviderResources)
|
||||
})))
|
||||
mux.Handle("GET /api/providers/{providerID}/access/status", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/providers/{providerID}/access/status", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleProviderAccessStatus(w, r, actions.GetProviderAccessStatus)
|
||||
})))
|
||||
mux.Handle("GET /api/providers/{providerID}/import-batches", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/providers/{providerID}/import-batches", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListProviderImportBatches(w, r, actions.ListProviderImportBatches)
|
||||
})))
|
||||
mux.Handle("POST /api/providers/{providerID}/access/assign-subscriptions", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/providers/{providerID}/access/assign-subscriptions", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleAssignAccessSubscriptions(w, r, actions.AssignAccessSubscriptions)
|
||||
})))
|
||||
mux.Handle("POST /api/providers/{providerID}/access/preview", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/providers/{providerID}/access/preview", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleAccessPreview(w, r, actions.AccessPreview)
|
||||
})))
|
||||
mux.Handle("POST /api/packs/install", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/packs/install", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleInstallPack(w, r, actions.InstallPack)
|
||||
})))
|
||||
mux.Handle("GET /api/packs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/packs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListPacks(w, r, actions.ListPacks)
|
||||
})))
|
||||
mux.Handle("GET /api/packs/{packID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/packs/{packID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetPack(w, r, actions.GetPack)
|
||||
})))
|
||||
mux.Handle("GET /api/packs/{packID}/providers", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/packs/{packID}/providers", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListPackProviders(w, r, actions.ListPackProviders)
|
||||
})))
|
||||
mux.Handle("POST /api/providers/{providerID}/preview-import", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/providers/{providerID}/preview-import", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlePreviewProvider(w, r, actions.PreviewProvider)
|
||||
})))
|
||||
mux.Handle("POST /api/providers/{providerID}/import", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/providers/{providerID}/import", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleImportProvider(w, r, actions.ImportProvider)
|
||||
})))
|
||||
mux.Handle("POST /api/providers/{providerID}/rollback", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/providers/{providerID}/rollback", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRollbackProvider(w, r, actions.RollbackProvider)
|
||||
})))
|
||||
mux.Handle("POST /api/providers/{providerID}/reconcile", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/providers/{providerID}/reconcile", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleReconcileProvider(w, r, actions.ReconcileProvider)
|
||||
})))
|
||||
mux.Handle("GET /api/hosts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/hosts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListHosts(w, r, actions.ListHosts)
|
||||
})))
|
||||
mux.Handle("GET /api/hosts/{hostID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("GET /api/hosts/{hostID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetHost(w, r, actions.GetHost)
|
||||
})))
|
||||
mux.Handle("POST /api/hosts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/hosts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleCreateHost(w, r, actions.CreateHost)
|
||||
})))
|
||||
mux.Handle("POST /api/hosts/{hostID}/probe", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("POST /api/hosts/{hostID}/probe", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleProbeHost(w, r, actions.ProbeHost)
|
||||
})))
|
||||
mux.Handle("DELETE /api/hosts/{hostID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("DELETE /api/hosts/{hostID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleDeleteHost(w, r, actions.DeleteHost)
|
||||
})))
|
||||
return mux
|
||||
@@ -501,21 +514,6 @@ func handlePublishProviderDraft(w http.ResponseWriter, r *http.Request, fn func(
|
||||
writeJSON(w, http.StatusOK, map[string]any{"publish": result})
|
||||
}
|
||||
|
||||
func requireAdminToken(token string, next http.Handler) http.Handler {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"})
|
||||
})
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if bearerToken(r) != token {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "missing or invalid admin token"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if !strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||
|
||||
@@ -11,12 +11,17 @@ const (
|
||||
EnvListenAddr = "SUB2API_CRM_LISTEN_ADDR"
|
||||
EnvSQLiteDSN = "SUB2API_CRM_SQLITE_DSN"
|
||||
EnvAdminToken = "SUB2API_CRM_ADMIN_TOKEN"
|
||||
EnvAdminUsername = "SUB2API_CRM_ADMIN_USERNAME"
|
||||
EnvAdminPassword = "SUB2API_CRM_ADMIN_PASSWORD"
|
||||
EnvAdminSessionTTL = "SUB2API_CRM_ADMIN_SESSION_TTL"
|
||||
EnvRepoRoot = "SUB2API_CRM_REPO_ROOT"
|
||||
EnvReconcileWorkerEnabled = "SUB2API_CRM_RECONCILE_WORKER_ENABLED"
|
||||
EnvReconcilePollInterval = "SUB2API_CRM_RECONCILE_POLL_INTERVAL"
|
||||
|
||||
DefaultListenAddr = ":8080"
|
||||
DefaultSQLiteDSN = "file:sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000"
|
||||
DefaultAdminUsername = "admin"
|
||||
DefaultAdminSessionTTL = 12 * time.Hour
|
||||
DefaultReconcilePollInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
@@ -44,6 +49,12 @@ type StartupConfig struct {
|
||||
Reconcile ReconcileConfig
|
||||
}
|
||||
|
||||
type AdminSessionConfig struct {
|
||||
Username string
|
||||
Password string
|
||||
SessionTTL time.Duration
|
||||
}
|
||||
|
||||
func LoadStartupFromEnv() (StartupConfig, error) {
|
||||
return loadStartupFromLookupEnv(os.LookupEnv)
|
||||
}
|
||||
@@ -76,6 +87,10 @@ func LoadAdminTokenFromEnv() (string, error) {
|
||||
return loadAdminTokenFromLookupEnv(os.LookupEnv)
|
||||
}
|
||||
|
||||
func LoadAdminSessionFromEnv() (AdminSessionConfig, error) {
|
||||
return loadAdminSessionFromLookupEnv(os.LookupEnv)
|
||||
}
|
||||
|
||||
func loadAdminTokenFromLookupEnv(lookup func(string) (string, bool)) (string, error) {
|
||||
token := strings.TrimSpace(readRequiredEnv(lookup, EnvAdminToken))
|
||||
if token == "" {
|
||||
@@ -85,6 +100,18 @@ func loadAdminTokenFromLookupEnv(lookup func(string) (string, bool)) (string, er
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func loadAdminSessionFromLookupEnv(lookup func(string) (string, bool)) (AdminSessionConfig, error) {
|
||||
ttl, err := readOptionalDurationEnv(lookup, EnvAdminSessionTTL, DefaultAdminSessionTTL)
|
||||
if err != nil {
|
||||
return AdminSessionConfig{}, err
|
||||
}
|
||||
return AdminSessionConfig{
|
||||
Username: readOptionalEnv(lookup, EnvAdminUsername, DefaultAdminUsername),
|
||||
Password: strings.TrimSpace(readOptionalEnv(lookup, EnvAdminPassword, "")),
|
||||
SessionTTL: ttl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readOptionalEnv(lookup func(string) (string, bool), key string, defaultValue string) string {
|
||||
value, ok := lookup(key)
|
||||
if !ok {
|
||||
|
||||
@@ -163,6 +163,65 @@ func TestLoadAdminTokenFromLookupEnv(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadAdminSessionFromLookupEnv(t *testing.T) {
|
||||
t.Run("uses defaults", func(t *testing.T) {
|
||||
cfg, err := loadAdminSessionFromLookupEnv(func(string) (string, bool) {
|
||||
return "", false
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Username != DefaultAdminUsername {
|
||||
t.Fatalf("Username = %q, want %q", cfg.Username, DefaultAdminUsername)
|
||||
}
|
||||
if cfg.Password != "" {
|
||||
t.Fatalf("Password = %q, want empty", cfg.Password)
|
||||
}
|
||||
if cfg.SessionTTL != DefaultAdminSessionTTL {
|
||||
t.Fatalf("SessionTTL = %s, want %s", cfg.SessionTTL, DefaultAdminSessionTTL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("loads custom values", func(t *testing.T) {
|
||||
cfg, err := loadAdminSessionFromLookupEnv(func(key string) (string, bool) {
|
||||
switch key {
|
||||
case EnvAdminUsername:
|
||||
return " portal-admin ", true
|
||||
case EnvAdminPassword:
|
||||
return " super-secret ", true
|
||||
case EnvAdminSessionTTL:
|
||||
return "4h", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Username != "portal-admin" {
|
||||
t.Fatalf("Username = %q, want portal-admin", cfg.Username)
|
||||
}
|
||||
if cfg.Password != "super-secret" {
|
||||
t.Fatalf("Password = %q, want super-secret", cfg.Password)
|
||||
}
|
||||
if cfg.SessionTTL != 4*time.Hour {
|
||||
t.Fatalf("SessionTTL = %s, want 4h", cfg.SessionTTL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid ttl", func(t *testing.T) {
|
||||
_, err := loadAdminSessionFromLookupEnv(func(key string) (string, bool) {
|
||||
if key == EnvAdminSessionTTL {
|
||||
return "bad", true
|
||||
}
|
||||
return "", false
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid session ttl")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Verify exported wrappers call the lookup versions.
|
||||
// We can't easily test LoadStartupFromEnv / LoadAdminTokenFromEnv
|
||||
// since they depend on os.LookupEnv, but we verify they compile and don't panic.
|
||||
|
||||
@@ -73,15 +73,22 @@ render_remote43_crm_env() {
|
||||
local sqlite_dsn="$2"
|
||||
local admin_token="$3"
|
||||
local repo_root="${4:-}"
|
||||
local sqlite_dsn_q admin_token_q repo_root_q
|
||||
local admin_username="${5:-admin}"
|
||||
local admin_password="${6:-$admin_token}"
|
||||
local sqlite_dsn_q admin_token_q repo_root_q admin_username_q admin_password_q
|
||||
printf -v sqlite_dsn_q '%q' "$sqlite_dsn"
|
||||
printf -v admin_token_q '%q' "$admin_token"
|
||||
printf -v repo_root_q '%q' "$repo_root"
|
||||
printf -v admin_username_q '%q' "$admin_username"
|
||||
printf -v admin_password_q '%q' "$admin_password"
|
||||
|
||||
cat <<EOF
|
||||
SUB2API_CRM_LISTEN_ADDR=127.0.0.1:$crm_port
|
||||
SUB2API_CRM_SQLITE_DSN=$sqlite_dsn_q
|
||||
SUB2API_CRM_ADMIN_TOKEN=$admin_token_q
|
||||
SUB2API_CRM_ADMIN_USERNAME=$admin_username_q
|
||||
SUB2API_CRM_ADMIN_PASSWORD=$admin_password_q
|
||||
SUB2API_CRM_ADMIN_SESSION_TTL=12h
|
||||
SUB2API_CRM_REPO_ROOT=$repo_root_q
|
||||
SUB2API_CRM_RECONCILE_WORKER_ENABLED=false
|
||||
EOF
|
||||
|
||||
@@ -23,6 +23,8 @@ ADMIN_PASSWORD="${ADMIN_PASSWORD:-Sub2API-Remote43-Temp-Admin-20260525}"
|
||||
JWT_SECRET="${JWT_SECRET:-$(remote43_random_hex 24)}"
|
||||
TOTP_ENCRYPTION_KEY="${TOTP_ENCRYPTION_KEY:-$(remote43_random_hex 32)}"
|
||||
CRM_ADMIN_TOKEN="${CRM_ADMIN_TOKEN:-$(remote43_random_hex 24)}"
|
||||
CRM_ADMIN_USERNAME="${CRM_ADMIN_USERNAME:-admin}"
|
||||
CRM_ADMIN_PASSWORD="${CRM_ADMIN_PASSWORD:-$CRM_ADMIN_TOKEN}"
|
||||
HOST_NAME="${HOST_NAME:-remote43-patched-${HOST_PORT}}"
|
||||
HOST_BINARY="${HOST_BINARY:-}"
|
||||
CRM_BINARY="${CRM_BINARY:-$ROOT_DIR/server}"
|
||||
@@ -172,7 +174,9 @@ main() {
|
||||
"$CRM_PORT" \
|
||||
"file:${REMOTE_CRM_DB_FILE}?_foreign_keys=on&_busy_timeout=5000" \
|
||||
"$CRM_ADMIN_TOKEN" \
|
||||
"$REMOTE_REPO_ROOT" > "$crm_env_file"
|
||||
"$REMOTE_REPO_ROOT" \
|
||||
"$CRM_ADMIN_USERNAME" \
|
||||
"$CRM_ADMIN_PASSWORD" > "$crm_env_file"
|
||||
render_remote43_bootstrap_script \
|
||||
"$REMOTE_ROOT" \
|
||||
"$REMOTE_HOST_ENV_FILE" \
|
||||
|
||||
@@ -658,7 +658,7 @@ run_test_remote43_patched_stack_renderers() {
|
||||
|
||||
local host_env crm_env bootstrap
|
||||
host_env="$(render_remote43_host_env "stack-pg" "stack-redis" "db-pass" "sub2api" "admin@sub2api.local" "admin-pass" "jwt-secret" "totp-secret")"
|
||||
crm_env="$(render_remote43_crm_env "18143" "file:/tmp/sub2api.db?_foreign_keys=on" "crm-token" "/home/ubuntu/sub2api-cn-relay-manager-git-current")"
|
||||
crm_env="$(render_remote43_crm_env "18143" "file:/tmp/sub2api.db?_foreign_keys=on" "crm-token" "/home/ubuntu/sub2api-cn-relay-manager-git-current" "portal-admin" "portal-pass")"
|
||||
bootstrap="$(render_remote43_bootstrap_script \
|
||||
"/home/ubuntu/test-stack" \
|
||||
"/home/ubuntu/test-stack/.env.host" \
|
||||
@@ -690,6 +690,9 @@ run_test_remote43_patched_stack_renderers() {
|
||||
assert_contains "$crm_env" "SUB2API_CRM_LISTEN_ADDR=127.0.0.1:18143"
|
||||
assert_contains "$crm_env" "SUB2API_CRM_SQLITE_DSN="
|
||||
assert_contains "$crm_env" "SUB2API_CRM_ADMIN_TOKEN=crm-token"
|
||||
assert_contains "$crm_env" "SUB2API_CRM_ADMIN_USERNAME=portal-admin"
|
||||
assert_contains "$crm_env" "SUB2API_CRM_ADMIN_PASSWORD=portal-pass"
|
||||
assert_contains "$crm_env" "SUB2API_CRM_ADMIN_SESSION_TTL=12h"
|
||||
assert_contains "$crm_env" "SUB2API_CRM_REPO_ROOT=/home/ubuntu/sub2api-cn-relay-manager-git-current"
|
||||
local sourced_dsn
|
||||
sourced_dsn="$(bash -lc 'set -a; source /dev/stdin; set +a; printf "%s" "$SUB2API_CRM_SQLITE_DSN"' <<<"$crm_env")"
|
||||
|
||||
@@ -71,6 +71,10 @@ assert_contains_file "$ADMIN_HOME_FILE" "/portal-admin-api"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "浏览器提交到 CRM"
|
||||
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Admin"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "管理员登录"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/login"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/logout"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/packs"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/hosts"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/providers/"
|
||||
@@ -86,8 +90,14 @@ assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Manifest 草稿"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal-admin-api"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/publish"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "发布 Commit Message"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "credentials: \"include\""
|
||||
|
||||
assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "管理员登录"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/login"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/logout"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "credentials: \"include\""
|
||||
|
||||
assert_contains_file "$NGINX_FILE" "location = /portal"
|
||||
assert_contains_file "$NGINX_FILE" "location = /portal/admin"
|
||||
|
||||
Reference in New Issue
Block a user