252 lines
8.4 KiB
JavaScript
252 lines
8.4 KiB
JavaScript
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();
|
||
}
|