Files
NixMsg/web/src/api/mock.ts
T

791 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ApiToken,
ApiTokenCreateResult,
BatchResult,
Endpoint,
EndpointCreateRequest,
EndpointCreateResult,
EndpointListQuery,
EndpointPatchRequest,
GroupDetail,
GroupFailed,
GroupSummary,
ImportItem,
MeInfo,
MessageDetail,
MessageSummary,
Overview,
PageResult,
RegistrationSettings,
RegistrationUpdateRequest,
RuntimeSettings,
} from "./types";
import { ApiError } from "./http";
const SESSION_KEY = "nixmsg_admin_mock_session";
const now = () => Date.now();
function randomId(prefix: string): string {
const hex = Math.random().toString(16).slice(2, 10);
return `${prefix}_${hex}`;
}
function randomPassword(len = 16): string {
const chars = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = "";
for (let i = 0; i < len; i++) {
out += chars[Math.floor(Math.random() * chars.length)];
}
return out;
}
function clone<T>(v: T): T {
return JSON.parse(JSON.stringify(v)) as T;
}
const endpoints: Endpoint[] = [
{
id: "device-1",
name: "gate",
remark: "lobby tablet",
source: "admin",
enabled: true,
online: true,
online_since_ms: now() - 3_600_000,
offline_since_ms: null,
talk_password_set: false,
default_delay_ms: 0,
created_at_ms: now() - 86_400_000,
login_locked: false,
session_issued_at_ms: now() - 3_600_000,
session_used_at_ms: now() - 60_000,
},
{
id: "device-2",
name: "warehouse",
remark: "",
source: "self",
enabled: true,
online: false,
online_since_ms: null,
offline_since_ms: now() - 7200_000,
talk_password_set: true,
default_delay_ms: 5000,
created_at_ms: now() - 172_800_000,
login_locked: true,
},
{
id: "ops-bot",
name: "ops-bot",
remark: "admin-provisioned",
source: "admin",
enabled: false,
online: false,
online_since_ms: null,
offline_since_ms: now() - 10_000_000,
talk_password_set: false,
default_delay_ms: 0,
created_at_ms: now() - 259_200_000,
login_locked: false,
},
];
const registration: RegistrationSettings = {
enabled: false,
code: "demo-reg-code-01",
updated_at_ms: now() - 86_400_000,
};
const tokens: ApiToken[] = [
{
id: "1",
name: "ops",
enabled: true,
created_at_ms: now() - 86_400_000,
last_used_at_ms: now() - 3_600_000,
},
];
let nextTokenId = 2;
interface GroupRow {
id: string;
name: string;
owner_id: string;
created_at_ms: number;
members: { id: string; joined_at_ms: number }[];
}
const groups: GroupRow[] = [
{
id: "g_demo0001",
name: "team-a",
owner_id: "device-1",
created_at_ms: now() - 50_000_000,
members: [
{ id: "device-1", joined_at_ms: now() - 50_000_000 },
{ id: "device-2", joined_at_ms: now() - 40_000_000 },
{ id: "ops-bot", joined_at_ms: now() - 30_000_000 },
],
},
];
const messages: MessageDetail[] = [
{
seq: 1,
id: "018f000000000000000000000001",
sender_id: "device-1",
dest_kind: "endpoint",
dest_id: "device-2",
state: "completed",
reason: "",
send_at_ms: now() - 3_600_000,
created_at_ms: now() - 3_601_000,
meta: {},
content_type: "text/plain",
deliveries: [
{
endpoint_id: "device-2",
state: "accepted",
reason: "",
attempts: 1,
pushed_at_ms: now() - 3_599_000,
updated_at_ms: now() - 3_598_000,
},
{
endpoint_id: "ops-bot",
state: "rejected",
reason: "disabled",
attempts: 1,
pushed_at_ms: now() - 3_599_000,
updated_at_ms: now() - 3_598_000,
},
],
next_cursor: "",
},
{
seq: 2,
id: "018f000000000000000000000002",
sender_id: "ops-bot",
dest_kind: "group",
dest_id: "g_demo0001",
state: "dispatched",
reason: "",
send_at_ms: now() - 600_000,
created_at_ms: now() - 601_000,
meta: {},
content_type: "application/json",
deliveries: [
{
endpoint_id: "device-1",
state: "accepted",
reason: "",
attempts: 1,
pushed_at_ms: now() - 599_000,
updated_at_ms: now() - 598_000,
},
{
endpoint_id: "device-2",
state: "pending",
reason: "",
attempts: 2,
pushed_at_ms: now() - 590_000,
updated_at_ms: now() - 580_000,
},
],
next_cursor: "",
},
{
seq: 3,
id: "018f000000000000000000000003",
sender_id: "device-2",
dest_kind: "endpoint",
dest_id: "device-1",
state: "scheduled",
reason: "",
send_at_ms: now() + 3_600_000,
created_at_ms: now() - 100_000,
meta: {},
content_type: "text/plain",
deliveries: [],
next_cursor: "",
},
];
const settings: RuntimeSettings = {
listen: ":7443",
admin_listen: "",
session_idle_days: 30,
record_retention_days: 7,
idempotency_hours: 24,
receipt_retention_days: 7,
sqlite_synchronous: "FULL",
limits: {
max_body_bytes: 262144,
max_meta_bytes: 4096,
max_frame_bytes: 786432,
max_ttl_seconds: 2592000,
max_schedule_seconds: 31536000,
max_group_members: 1000,
grace_seconds: 60,
ack_timeout_seconds: 300,
delivery_window: 32,
receipt_window: 64,
requests_per_second: 50,
max_pending_per_sender: 10000,
max_pending_per_receiver: 10000,
},
};
const startedAt = now();
function requireSession(): void {
if (sessionStorage.getItem(SESSION_KEY) !== "1") {
throw new ApiError("unauthorized", "未登录", 401);
}
}
function paginate<T>(items: T[], cursor?: string, limit = 50): PageResult<T> {
const offset = cursor ? Number.parseInt(cursor, 10) || 0 : 0;
const size = Math.min(Math.max(limit, 1), 200);
const slice = items.slice(offset, offset + size);
const next = offset + size < items.length ? String(offset + size) : "";
return { items: clone(slice), next_cursor: next, total: items.length };
}
function endpointName(id: string): string {
return endpoints.find((e) => e.id === id)?.name ?? "";
}
function toGroupSummary(g: GroupRow): GroupSummary {
return {
id: g.id,
name: g.name,
owner_id: g.owner_id,
member_count: g.members.length,
created_at_ms: g.created_at_ms,
};
}
function toMessageSummary(m: MessageDetail): MessageSummary {
const counts = { pending: 0, accepted: 0, recalled: 0, expired: 0, dropped: 0, rejected: 0 };
for (const d of m.deliveries) {
const key = d.state as keyof typeof counts;
if (key in counts) {
counts[key] += 1;
}
}
return {
seq: m.seq,
id: m.id,
sender_id: m.sender_id,
dest_kind: m.dest_kind,
dest_id: m.dest_id,
state: m.state as MessageSummary["state"],
reason: m.reason,
send_at_ms: m.send_at_ms,
created_at_ms: m.created_at_ms,
keep: false,
receipt: true,
content_type: m.content_type,
delivery_counts: counts,
};
}
export const mockApi = {
async login(username: string, password: string): Promise<{ username: string }> {
if (username !== "admin" || password !== "adminpassword") {
throw new ApiError("unauthorized", "用户名或密码错误", 401);
}
sessionStorage.setItem(SESSION_KEY, "1");
return { username: "admin" };
},
async logout(): Promise<Record<string, never>> {
sessionStorage.removeItem(SESSION_KEY);
return {};
},
async me(): Promise<MeInfo> {
requireSession();
return { username: "admin", auth: "cookie" };
},
async changePassword(oldPassword: string, newPassword: string): Promise<Record<string, never>> {
requireSession();
if (oldPassword !== "adminpassword") {
throw new ApiError("unauthorized", "原密码错误", 401);
}
if (newPassword.length < 12) {
throw new ApiError("bad_request", "新密码至少 12 位", 400);
}
return {};
},
async overview(): Promise<Overview> {
requireSession();
return {
version: "0.1.0-mock",
endpoints_total: endpoints.length,
endpoints_online: endpoints.filter((e) => e.online).length,
endpoints_disabled: endpoints.filter((e) => !e.enabled).length,
endpoints_self: endpoints.filter((e) => e.source === "self").length,
groups_total: groups.length,
messages_pending: messages.filter((m) => m.state === "dispatched").length,
messages_scheduled: messages.filter((m) => m.state === "scheduled").length,
uptime_ms: now() - startedAt,
};
},
async listEndpoints(q: EndpointListQuery = {}): Promise<PageResult<Endpoint>> {
requireSession();
let list = [...endpoints];
if (q.source) {
list = list.filter((e) => e.source === q.source);
}
if (q.online === true || q.online === false) {
list = list.filter((e) => e.online === q.online);
}
if (q.enabled === true || q.enabled === false) {
list = list.filter((e) => e.enabled === q.enabled);
}
if (q.query) {
const needle = q.query.toLowerCase();
list = list.filter(
(e) => e.id.toLowerCase().includes(needle) || e.name.toLowerCase().includes(needle),
);
}
return paginate(list, q.cursor, q.limit);
},
async createEndpoint(body: EndpointCreateRequest): Promise<EndpointCreateResult> {
requireSession();
const id = (body.id || "").trim() || randomId("e");
if (endpoints.some((e) => e.id === id)) {
throw new ApiError("id_taken", "编号已占用", 409);
}
const provided = (body.login_password || "").trim();
const loginPassword = provided || randomPassword();
endpoints.unshift({
id,
name: body.name,
remark: body.remark ?? "",
source: "admin",
enabled: true,
online: false,
online_since_ms: null,
offline_since_ms: now(),
talk_password_set: Boolean(body.talk_password),
default_delay_ms: (body.default_delay_seconds ?? 0) * 1000,
created_at_ms: now(),
login_locked: false,
});
return provided ? { id } : { id, login_password: loginPassword };
},
async importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> {
requireSession();
const lines = csvText.replace(/^\uFEFF/, "").split(/\r?\n/).filter((l) => l.trim());
if (lines.length < 2) {
throw new ApiError("bad_request", "CSV 校验失败", 400, {
errors: [{ line: 1, reason: "表头不正确" }],
});
}
const header = lines[0].split(",").map((s) => s.trim());
const expected = ["id", "name", "login_password", "talk_password", "default_delay_seconds", "remark"];
if (expected.some((h, i) => header[i] !== h)) {
throw new ApiError("bad_request", "CSV 校验失败", 400, {
errors: [{ line: 1, reason: "?????" }],
});
}
const errors: { line: number; reason: string }[] = [];
const rows: ImportItem[] = [];
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(",");
const id = (cols[0] || "").trim() || randomId("e");
const name = (cols[1] || "").trim();
if (!name) {
errors.push({ line: i + 1, reason: "名称不能为空" });
continue;
}
if (endpoints.some((e) => e.id === id) || rows.some((r) => r.id === id)) {
errors.push({ line: i + 1, reason: "编号占用" });
continue;
}
rows.push({
id,
name,
login_password: (cols[2] || "").trim() || randomPassword(),
});
}
if (errors.length) {
throw new ApiError("bad_request", "CSV 校验失败", 400, { errors });
}
for (const r of rows) {
endpoints.unshift({
id: r.id,
name: r.name,
remark: "",
source: "admin",
enabled: true,
online: false,
online_since_ms: null,
offline_since_ms: now(),
talk_password_set: false,
default_delay_ms: 0,
created_at_ms: now(),
login_locked: false,
});
}
return { items: rows };
},
async batchEndpoints(ids: string[], action: "disable" | "enable" | "delete"): Promise<BatchResult> {
requireSession();
const ok_ids: string[] = [];
const failed: { id: string; code: string }[] = [];
for (const id of ids) {
const idx = endpoints.findIndex((e) => e.id === id);
if (idx < 0) {
failed.push({ id, code: "not_found" });
continue;
}
if (action === "delete") {
endpoints.splice(idx, 1);
} else if (action === "disable") {
endpoints[idx].enabled = false;
} else {
endpoints[idx].enabled = true;
}
ok_ids.push(id);
}
return { ok_ids, failed };
},
async getEndpoint(id: string): Promise<Endpoint> {
requireSession();
const e = endpoints.find((x) => x.id === id);
if (!e) {
throw new ApiError("not_found", "端不存在", 404);
}
return clone(e);
},
async patchEndpoint(id: string, body: EndpointPatchRequest): Promise<Endpoint> {
requireSession();
const e = endpoints.find((x) => x.id === id);
if (!e) {
throw new ApiError("not_found", "端不存在", 404);
}
if (body.name !== undefined) e.name = body.name;
if (body.remark !== undefined) e.remark = body.remark;
if (body.enabled !== undefined) e.enabled = body.enabled;
if (body.default_delay_seconds !== undefined) {
e.default_delay_ms = body.default_delay_seconds * 1000;
}
return clone(e);
},
async deleteEndpoint(id: string): Promise<Record<string, never>> {
requireSession();
const idx = endpoints.findIndex((e) => e.id === id);
if (idx < 0) {
throw new ApiError("not_found", "端不存在", 404);
}
endpoints.splice(idx, 1);
return {};
},
async kickEndpoint(id: string): Promise<{ kicked: boolean }> {
requireSession();
const e = endpoints.find((x) => x.id === id);
if (!e) {
throw new ApiError("not_found", "端不存在", 404);
}
const kicked = e.online;
if (kicked) {
e.online = false;
e.online_since_ms = null;
e.offline_since_ms = now();
}
return { kicked };
},
async resetLoginPassword(id: string, loginPassword?: string): Promise<{ login_password: string }> {
requireSession();
const e = endpoints.find((x) => x.id === id);
if (!e) {
throw new ApiError("not_found", "端不存在", 404);
}
return { login_password: (loginPassword || "").trim() || randomPassword() };
},
async setTalkPassword(id: string, talkPassword: string): Promise<{ talk_password_set: boolean }> {
requireSession();
const e = endpoints.find((x) => x.id === id);
if (!e) {
throw new ApiError("not_found", "端不存在", 404);
}
e.talk_password_set = talkPassword.length > 0;
return { talk_password_set: e.talk_password_set };
},
async unlockEndpoint(id: string): Promise<Record<string, never>> {
requireSession();
const e = endpoints.find((x) => x.id === id);
if (!e) {
throw new ApiError("not_found", "端不存在", 404);
}
e.login_locked = false;
return {};
},
async getRegistration(): Promise<RegistrationSettings> {
requireSession();
return clone(registration);
},
async updateRegistration(body: RegistrationUpdateRequest): Promise<RegistrationSettings> {
requireSession();
if (body.enabled !== undefined) registration.enabled = body.enabled;
if (body.generate) {
registration.code = randomPassword(16);
} else if (body.code !== undefined) {
if (body.code.length < 8 || body.code.length > 64) {
throw new ApiError("bad_request", "安全码须为 8–64 字符", 400);
}
registration.code = body.code;
}
registration.updated_at_ms = now();
return clone(registration);
},
async listTokens(): Promise<PageResult<ApiToken>> {
requireSession();
return { items: clone(tokens), next_cursor: "", total: tokens.length };
},
async createToken(name: string): Promise<ApiTokenCreateResult> {
requireSession();
const item: ApiToken = {
id: String(nextTokenId++),
name,
enabled: true,
created_at_ms: now(),
last_used_at_ms: null,
};
tokens.unshift(item);
return {
id: item.id,
name: item.name,
token: `nxm_${randomPassword(32)}`,
created_at_ms: item.created_at_ms,
};
},
async patchToken(id: string, body: { name?: string; enabled?: boolean }): Promise<ApiToken> {
requireSession();
const t = tokens.find((x) => x.id === id);
if (!t) {
throw new ApiError("not_found", "令牌不存在", 404);
}
if (body.name !== undefined) t.name = body.name;
if (body.enabled !== undefined) t.enabled = body.enabled;
return clone(t);
},
async deleteToken(id: string): Promise<Record<string, never>> {
requireSession();
const idx = tokens.findIndex((x) => x.id === id);
if (idx < 0) {
throw new ApiError("not_found", "令牌不存在", 404);
}
tokens.splice(idx, 1);
return {};
},
async listGroups(query?: string, cursor?: string, limit?: number): Promise<PageResult<GroupSummary>> {
requireSession();
let list = groups.map(toGroupSummary);
if (query) {
const n = query.toLowerCase();
list = list.filter((g) => g.name.toLowerCase().includes(n));
}
return paginate(list, cursor, limit);
},
async createGroup(body: {
id?: string;
name: string;
owner_id: string;
member_ids?: string[];
}): Promise<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }> {
requireSession();
const id = (body.id || "").trim() || randomId("g");
const failed: GroupFailed[] = [];
const memberIds = new Set<string>([body.owner_id, ...(body.member_ids ?? [])]);
const members: GroupRow["members"] = [];
for (const mid of memberIds) {
if (!endpoints.some((e) => e.id === mid)) {
failed.push({ id: mid, code: "not_found" });
continue;
}
members.push({ id: mid, joined_at_ms: now() });
}
if (!members.some((m) => m.id === body.owner_id)) {
throw new ApiError("bad_request", "群主必须存在", 400);
}
groups.unshift({
id,
name: body.name,
owner_id: body.owner_id,
created_at_ms: now(),
members,
});
return { id, name: body.name, owner_id: body.owner_id, failed };
},
async getGroup(id: string, cursor?: string, limit?: number): Promise<GroupDetail> {
requireSession();
const g = groups.find((x) => x.id === id);
if (!g) {
throw new ApiError("not_found", "群不存在", 404);
}
const all = g.members.map((m) => {
const ep = endpoints.find((e) => e.id === m.id);
return {
id: m.id,
name: ep?.name ?? endpointName(m.id),
online: ep?.online ?? false,
joined_at_ms: m.joined_at_ms,
};
});
const page = paginate(all, cursor, limit);
return {
id: g.id,
name: g.name,
owner_id: g.owner_id,
created_at_ms: g.created_at_ms,
member_total: all.length,
members: page.items,
next_cursor: page.next_cursor,
};
},
async renameGroup(id: string, name: string): Promise<GroupSummary> {
requireSession();
const g = groups.find((x) => x.id === id);
if (!g) {
throw new ApiError("not_found", "群不存在", 404);
}
g.name = name;
return toGroupSummary(g);
},
async deleteGroup(id: string): Promise<Record<string, never>> {
requireSession();
const idx = groups.findIndex((x) => x.id === id);
if (idx < 0) {
throw new ApiError("not_found", "群不存在", 404);
}
groups.splice(idx, 1);
return {};
},
async addGroupMembers(id: string, memberIds: string[]): Promise<{ failed: GroupFailed[]; added: number }> {
requireSession();
const g = groups.find((x) => x.id === id);
if (!g) {
throw new ApiError("not_found", "群不存在", 404);
}
const failed: GroupFailed[] = [];
let added = 0;
for (const mid of memberIds) {
if (!endpoints.some((e) => e.id === mid)) {
failed.push({ id: mid, code: "not_found" });
continue;
}
if (!g.members.some((m) => m.id === mid)) {
g.members.push({ id: mid, joined_at_ms: now() });
added++;
}
}
return { failed, added };
},
async removeGroupMember(id: string, endpointId: string): Promise<Record<string, never>> {
requireSession();
const g = groups.find((x) => x.id === id);
if (!g) {
throw new ApiError("not_found", "群不存在", 404);
}
g.members = g.members.filter((m) => m.id !== endpointId);
return {};
},
async transferGroup(id: string, endpointId: string): Promise<{ owner_id: string }> {
requireSession();
const g = groups.find((x) => x.id === id);
if (!g) {
throw new ApiError("not_found", "群不存在", 404);
}
if (!g.members.some((m) => m.id === endpointId)) {
throw new ApiError("bad_request", "新群主须为成员", 400);
}
g.owner_id = endpointId;
return { owner_id: endpointId };
},
async listMessages(q: {
cursor?: string;
limit?: number;
sender_id?: string;
endpoint_id?: string;
group_id?: string;
state?: string;
from_ms?: number;
to_ms?: number;
}): Promise<PageResult<MessageSummary>> {
requireSession();
let list = messages.map(toMessageSummary);
if (q.sender_id) list = list.filter((m) => m.sender_id === q.sender_id);
if (q.group_id) list = list.filter((m) => m.dest_kind === "group" && m.dest_id === q.group_id);
if (q.endpoint_id) {
list = list.filter((m) => {
const detail = messages.find((x) => x.seq === m.seq);
return detail?.deliveries.some((d) => d.endpoint_id === q.endpoint_id);
});
}
if (q.state) list = list.filter((m) => m.state === q.state);
if (q.from_ms != null) list = list.filter((m) => m.created_at_ms >= q.from_ms!);
if (q.to_ms != null) list = list.filter((m) => m.created_at_ms <= q.to_ms!);
return paginate(list, q.cursor, q.limit);
},
async getMessage(seq: number, cursor?: string, limit?: number): Promise<MessageDetail> {
requireSession();
const m = messages.find((x) => x.seq === seq);
if (!m) {
throw new ApiError("not_found", "消息不存在", 404);
}
const page = paginate(m.deliveries, cursor, limit ?? 200);
return {
...clone(m),
deliveries: page.items,
next_cursor: page.next_cursor,
};
},
async getSettings(): Promise<RuntimeSettings> {
requireSession();
return clone(settings);
},
_resetSession(): void {
sessionStorage.removeItem(SESSION_KEY);
},
_setSession(): void {
sessionStorage.setItem(SESSION_KEY, "1");
},
};