Files
2026-05-13 12:19:36 +08:00

252 lines
8.4 KiB
JavaScript
Raw Permalink 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 { marked } from "marked";
const DEFAULT_DEV_API_BASE = "http://localhost:8080";
const DEFAULT_PROD_API_BASE = "https://auth.api.shumengya.top";
export const API_BASE = (() => {
const configuredBase = import.meta.env.VITE_API_BASE?.trim();
const fallbackBase = import.meta.env.DEV ? DEFAULT_DEV_API_BASE : DEFAULT_PROD_API_BASE;
return (configuredBase || fallbackBase).replace(/\/+$/, "");
})();
/** OAuth 成功回跳:当前页(不含 # fragment与后端 `allowedReturnPrefixes` 白名单配合 */
export function getOAuthReturnTo() {
if (typeof window === "undefined") return "";
return `${window.location.origin}${window.location.pathname}${window.location.search}`;
}
/** `public/logo192.png`,含 Vite `base` 前缀,避免子路径部署时顶栏/开屏裂图 */
export const LOGO_192_SRC = `${import.meta.env.BASE_URL}logo192.png`;
/** 全站随机背景(`GET /api/random?format=json`,见 https://randbg.api.smyhub.com */
export const RAND_BG_API_ORIGIN = "https://randbg.api.smyhub.com";
const SELF_SERVICE_ACCOUNT_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
/** 与自助注册规则一致:随机小写字母与数字(默认长度 10 */
export function randomSelfServiceAccount(length = 10) {
const n = Math.max(3, Math.min(32, Number(length) || 10));
const buf = new Uint8Array(n);
crypto.getRandomValues(buf);
let s = "";
for (let i = 0; i < n; i++) {
s += SELF_SERVICE_ACCOUNT_ALPHABET[buf[i] % SELF_SERVICE_ACCOUNT_ALPHABET.length];
}
return s;
}
/** 输入时规范为自助注册允许的字符(小写、数字),超长截断 */
export function normalizeSelfServiceAccountInput(raw, maxLen = 32) {
const m = Math.max(3, Math.min(32, maxLen));
return String(raw || "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
.slice(0, m);
}
/** 浏览器侧 IP/地理(与后端写入「最后访问」字段配合使用) */
export const CLIENT_GEO_LOOKUP_URL = "https://cf-ip-geo.smyhub.com/api";
export function formatVisitDisplayLocation(payload) {
const g = payload?.geo;
if (!g || typeof g !== "object") return "";
const parts = [g.countryName, g.regionName, g.cityName].filter(
(x) => typeof x === "string" && x.trim()
);
return parts.join(" ");
}
export async function fetchClientVisitMeta(timeoutMs = 2800) {
const ctrl = new AbortController();
const id = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(CLIENT_GEO_LOOKUP_URL, {
credentials: "omit",
signal: ctrl.signal
});
clearTimeout(id);
if (!res.ok) return { ip: "", displayLocation: "" };
const data = await res.json();
const ip = typeof data.ip === "string" ? data.ip.trim() : "";
const displayLocation = formatVisitDisplayLocation(data);
return { ip, displayLocation };
} catch {
clearTimeout(id);
return { ip: "", displayLocation: "" };
}
}
marked.setOptions({ breaks: true });
export { marked };
export const emptyForm = {
account: "",
password: "",
username: "",
email: "",
level: 0,
sproutCoins: 0,
secondaryEmails: "",
phone: "",
avatarUrl: "",
websiteUrl: "",
bio: "",
banned: false,
banReason: ""
};
/** 后端封禁响应:`error` + 可选 `banReason`(登录/me 等 403 */
export function formatAuthBanMessage(data) {
const base = (data && data.error && String(data.error).trim()) || "account is banned";
const reason = data && data.banReason != null && String(data.banReason).trim();
return reason ? `${base}${reason}` : base;
}
/** 展示用:从完整 URL 得到「主机 + 路径」短文案 */
export function formatWebsiteLabel(url) {
if (!url || typeof url !== "string") return "";
try {
const u = new URL(url);
const path = u.pathname === "/" ? "" : u.pathname;
return u.host + path;
} catch {
return url;
}
}
/** 用户 `createdAt`RFC3339→ 本地可读注册时间 */
export function formatUserRegisteredAt(iso) {
if (!iso || typeof iso !== "string") return "未知";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "long",
timeStyle: "short"
}).format(d);
}
/** RFC3339 / 后端存储时间 → 本地可读(用于应用接入记录等) */
export function formatIsoDateTimeReadable(iso) {
if (!iso || typeof iso !== "string") return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "medium"
}).format(d);
}
export const parseEmailList = (value) =>
value.split(",").map((item) => item.trim()).filter(Boolean);
/** 可点击发信:`mailto:`,排除明显非法地址 */
export function mailtoHref(address) {
const a = typeof address === "string" ? address.trim() : "";
if (!a || /\s/.test(a) || a.includes("<") || a.includes(">")) return null;
const at = a.indexOf("@");
if (at < 1 || at === a.length - 1) return null;
return `mailto:${a}`;
}
export function getPublicAccountFromPathname(pathname) {
if (pathname !== "/user" && !pathname.startsWith("/user/")) return "";
const raw = pathname === "/user" ? "" : pathname.slice("/user/".length).split("/")[0];
if (!raw) return "";
try {
return decodeURIComponent(raw).trim();
} catch {
return raw.trim();
}
}
export function getAuthFlowFromSearch(search) {
const params = new URLSearchParams(search);
const redirectUri = (params.get("redirect_uri") || params.get("return_url") || "").trim();
const scopeRaw = (params.get("scope") || "").trim();
const scopes = scopeRaw
? scopeRaw.split(/[\s+]+/).map((s) => s.trim()).filter(Boolean)
: [];
return {
redirectUri,
state: (params.get("state") || "").trim(),
prompt: (params.get("prompt") || "").trim(),
clientId: (params.get("client_id") || "").trim(),
clientName: (params.get("client_name") || "").trim(),
scopes,
scopeRaw
};
}
/** 授权页展示用:回跳地址可读标签(主机 + 路径) */
export function formatOAuthRedirectLabel(redirectUri) {
if (!redirectUri || typeof redirectUri !== "string") return "—";
const t = redirectUri.trim();
if (!t) return "—";
try {
const u = new URL(t, typeof window !== "undefined" ? window.location.href : "https://example.com");
const path = u.pathname === "/" ? "" : u.pathname;
return `${u.host}${path}` || u.host;
} catch {
return t.length > 64 ? `${t.slice(0, 61)}` : t;
}
}
const AUTH_CLIENT_ID_KEY = "sproutgate_auth_client_id";
const AUTH_CLIENT_NAME_KEY = "sproutgate_auth_client_name";
/** 从统一登录 URL 上的 `client_id` / `client_name` 写入 session后续请求带 `X-Auth-Client` 头以便记录接入应用 */
export function persistAuthClientFromFlow(authFlow) {
if (!authFlow?.clientId) return;
try {
sessionStorage.setItem(AUTH_CLIENT_ID_KEY, authFlow.clientId);
if (authFlow.clientName) sessionStorage.setItem(AUTH_CLIENT_NAME_KEY, authFlow.clientName);
} catch {
/* ignore */
}
}
export function authClientFetchHeaders() {
try {
const id = (sessionStorage.getItem(AUTH_CLIENT_ID_KEY) || "").trim();
const name = (sessionStorage.getItem(AUTH_CLIENT_NAME_KEY) || "").trim();
const h = {};
if (id) h["X-Auth-Client"] = id;
if (name) h["X-Auth-Client-Name"] = name;
return h;
} catch {
return {};
}
}
export function clearAuthClientContext() {
try {
sessionStorage.removeItem(AUTH_CLIENT_ID_KEY);
sessionStorage.removeItem(AUTH_CLIENT_NAME_KEY);
} catch {
/* ignore */
}
}
export function buildAuthCallbackUrl(redirectUri, payload) {
const url = new URL(redirectUri, window.location.href);
const hashParams = new URLSearchParams(url.hash ? url.hash.slice(1) : "");
Object.entries(payload).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") return;
hashParams.set(key, String(value));
});
url.hash = hashParams.toString();
return url.toString();
}
/** 用户拒绝授权:回跳到应用地址,在 URL hash 中携带 OAuth 风格 error与成功回跳一致使用 fragment */
export function buildAuthDenyCallbackUrl(redirectUri, state = "") {
if (!redirectUri || typeof redirectUri !== "string") return "";
const url = new URL(redirectUri, window.location.href);
const hashParams = new URLSearchParams();
hashParams.set("error", "access_denied");
hashParams.set("error_description", "resource_owner_denied");
if (state) hashParams.set("state", String(state));
url.hash = hashParams.toString();
return url.toString();
}