Files
UHD_Media_Tool/apps/web/app/api/sites.ts
T
rootandClaude Opus 5.5 28974d1e1a feat(browser): unify site credentials with browser profiles (BR-2)
- Inject the site account Cookie (manual + auto-login session, session wins)
  into the camofox profile before the first tab opens; password accounts
  without a session run refresh-login first; API Key sites are skipped.
- Cookie expired after injection -> credential.status=expired; cookie
  accounts go to waiting/login_required ("log in once, applies site-wide").
- Write back site-domain cookies from the browser on handover, session close,
  every 10 min of activity, and from refresh-login when the stored cookie is
  invalid (verified before saving). user_info.cookieOrigin records
  source=browser; source shown as manual/login/browser in the origin view.
- New camofox plugin umt-cookies (GET/POST /umt/sessions/:userId/cookies
  under the access key); profile dir 0700/files 0600 in the desktop image.
- Single entry: sources-identity/service/credentials.ts.
- Real check: HHClub cookie injected into a fresh profile -> home page
  logged in (logout link present), 5 cookies read back.

Requires rebuilding umt-browser-desktop (docker compose build camofox &&
docker compose up -d camofox) to take effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 15:25:07 +08:00

225 lines
7.9 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AuditEvent,
cursorPage,
Id,
Site,
SiteAccount,
type SiteCreate,
type SiteCredentialsBody,
SiteCredentialsResult,
SiteDefinitionView,
SiteHealthResult,
SiteListItem,
SiteRefreshLoginAccepted,
SiteSnapshot,
type SiteUpdate,
SiteUserDataPoint,
} from "@umt/contracts";
import { z } from "zod";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed } from "./parse.ts";
// 站点、站点账号、站点快照(W2-B:/sites、/site-accounts、/sites/{id}/snapshot(s))
// 站点中心(W9-A):include=accounts、凭证 PUT、健康检测、刷新登录、用户数据、删除
const SitePage = cursorPage(SiteListItem);
const AccountPage = cursorPage(SiteAccount);
const SnapshotPage = cursorPage(SiteSnapshot);
export const siteKeys = {
all: ["sites"] as const,
list: () => ["sites", "list"] as const,
one: (id: string) => ["sites", "one", id] as const,
accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const,
snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const,
definitions: () => ["sites", "definitions"] as const,
userData: (siteId: string, accountId?: string, days = 30) =>
["sites", "user-data", siteId, accountId ?? "first", days] as const,
loginHistory: (accountId: string) => ["sites", "login-history", accountId] as const,
};
/** 站点列表(附账号与整体账号状态,站点页一次请求) */
export function useSites() {
return useQuery({
queryKey: siteKeys.list(),
queryFn: ({ signal }) =>
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts" }, signal }),
});
}
export function useSite(id: string | undefined) {
return useQuery({
queryKey: siteKeys.one(id ?? ""),
enabled: !!id && Id.safeParse(id).success,
queryFn: ({ signal }) => apiParsed(Site, "/api/v1/sites/:id", { pathParams: { id: id ?? "" }, signal }),
});
}
export function useSiteAccounts(siteId?: string) {
return useQuery({
queryKey: siteKeys.accounts(siteId),
queryFn: ({ signal }) =>
apiParsed(AccountPage, "/api/v1/site-accounts", { params: { limit: 200, siteId }, signal }),
});
}
export function useSiteDefinitions() {
return useQuery({
queryKey: siteKeys.definitions(),
staleTime: 5 * 60 * TTL_SECOND,
queryFn: ({ signal }) =>
apiParsed(z.object({ items: z.array(SiteDefinitionView) }), "/api/v1/site-definitions", { signal }),
});
}
/** 快照/补查记录;有 running 时 5 秒轮询 */
export function useSiteSnapshots(siteId: string | undefined, limit = 20) {
return useQuery({
queryKey: siteKeys.snapshots(siteId ?? ""),
enabled: !!siteId,
queryFn: ({ signal }) =>
apiParsed(SnapshotPage, "/api/v1/sites/:id/snapshots", {
pathParams: { id: siteId ?? "" },
params: { limit },
signal,
}),
refetchInterval: (q) => (q.state.data?.items.some((s) => s.status === "running") ? 5 * TTL_SECOND : false),
});
}
/** 启用 / 停用站点 */
export function useToggleSite() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
apiMutate(`/api/v1/sites/:id/${enabled ? "enable" : "disable"}`, { pathParams: { id } }).then((d) =>
Site.parse(d),
),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 手动快照(受站点限流),返回任务 ID */
export function useSnapshotSite() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, maxPages }: { id: string; maxPages?: number }) =>
apiMutate(`/api/v1/sites/:id/snapshot`, { pathParams: { id }, body: maxPages ? { maxPages } : {} }).then((d) =>
z.object({ taskId: Id }).parse(d),
),
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: siteKeys.snapshots(v.id) });
qc.invalidateQueries({ queryKey: siteKeys.list() });
},
});
}
/** 快照新鲜度:从未快照或超过 2 个周期视为过期(与服务端 WorkbenchCoverage.stale 同口径) */
export function snapshotStale(site: Pick<Site, "lastSnapshotAt" | "snapshotIntervalSec">, now = Date.now()) {
if (!site.lastSnapshotAt) return true;
const interval = (site.snapshotIntervalSec ?? 3600) * 1000;
return now - new Date(site.lastSnapshotAt).getTime() > interval * 2;
}
/** 登记站点(POST /sites) */
export function useCreateSite() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: SiteCreate) => apiMutate("/api/v1/sites", { body }).then((d) => Site.parse(d)),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 修改站点(名称、域名、限流、快照、代理) */
export function useUpdateSite() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: SiteUpdate }) =>
apiMutate("/api/v1/sites/:id", { method: "PATCH", pathParams: { id }, body }).then((d) => Site.parse(d)),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 删除站点:资源标 site_disabled 保留,账号与凭证删除 */
export function useDeleteSite() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiMutate("/api/v1/sites/:id", { method: "DELETE", pathParams: { id } }),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 写凭证(按 kind 判别);只写不回显,账号回到未验证 */
export function usePutCredentials() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ siteId, body }: { siteId: string; body: SiteCredentialsBody }) =>
apiMutate("/api/v1/sites/:id/credentials", { method: "PUT", pathParams: { id: siteId }, body }).then((d) =>
SiteCredentialsResult.parse(d),
),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 删除站点账号及其凭证 */
export function useDeleteSiteAccount() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiMutate("/api/v1/site-accounts/:id", { method: "DELETE", pathParams: { id } }),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 检测健康(同步,≤20 s) */
export function useHealthCheck() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiMutate("/api/v1/sites/:id/health-check", { pathParams: { id }, body: {} }).then((d) =>
SiteHealthResult.parse(d),
),
onSettled: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 刷新登录;界面上用 wait=true 同步拿结果(≤30 s) */
export function useRefreshLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, accountId }: { id: string; accountId?: string }) =>
apiMutate("/api/v1/sites/:id/refresh-login", {
pathParams: { id },
body: { wait: true, ...(accountId ? { accountId } : {}) },
}).then((d) => SiteRefreshLoginAccepted.parse(d)),
onSettled: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 用户数据按天历史 */
export function useSiteUserData(siteId: string | undefined, accountId?: string, days = 30) {
return useQuery({
queryKey: siteKeys.userData(siteId ?? "", accountId, days),
enabled: !!siteId,
queryFn: ({ signal }) =>
apiParsed(z.object({ items: z.array(SiteUserDataPoint) }), "/api/v1/sites/:id/user-data", {
pathParams: { id: siteId ?? "" },
params: { days, accountId },
signal,
}),
});
}
/** 登录历史:审计里该账号的凭证与登录事件 */
export function useLoginHistory(accountId: string | undefined) {
return useQuery({
queryKey: siteKeys.loginHistory(accountId ?? ""),
enabled: !!accountId,
queryFn: ({ signal }) =>
apiParsed(cursorPage(AuditEvent), "/api/v1/audit-events", {
params: { targetType: "site_account", targetId: accountId, limit: 20 },
signal,
}),
});
}