feat: ship public ops features and cache docker builds
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
This commit is contained in:
@@ -16,7 +16,13 @@ function normalizeApiBaseUrl(value?: string | null) {
|
||||
return value?.trim().replace(/\/$/, '') ?? '';
|
||||
}
|
||||
|
||||
function getRuntimeEnv(name: 'PUBLIC_API_BASE_URL' | 'INTERNAL_API_BASE_URL') {
|
||||
function getRuntimeEnv(
|
||||
name:
|
||||
| 'PUBLIC_API_BASE_URL'
|
||||
| 'INTERNAL_API_BASE_URL'
|
||||
| 'PUBLIC_COMMENT_TURNSTILE_SITE_KEY'
|
||||
| 'PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY',
|
||||
) {
|
||||
const runtimeProcess = (globalThis as typeof globalThis & {
|
||||
process?: {
|
||||
env?: Record<string, string | undefined>;
|
||||
@@ -31,6 +37,10 @@ function toUrlLike(value: string | URL) {
|
||||
}
|
||||
|
||||
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
|
||||
const buildTimeCommentTurnstileSiteKey =
|
||||
import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? '';
|
||||
const buildTimeWebPushVapidPublicKey =
|
||||
import.meta.env.PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? '';
|
||||
|
||||
export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
|
||||
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL');
|
||||
@@ -63,6 +73,18 @@ export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
|
||||
return resolvePublicApiBaseUrl(requestUrl);
|
||||
}
|
||||
|
||||
export function resolvePublicCommentTurnstileSiteKey() {
|
||||
return (
|
||||
getRuntimeEnv('PUBLIC_COMMENT_TURNSTILE_SITE_KEY') || buildTimeCommentTurnstileSiteKey
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePublicWebPushVapidPublicKey() {
|
||||
return (
|
||||
getRuntimeEnv('PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY') || buildTimeWebPushVapidPublicKey
|
||||
);
|
||||
}
|
||||
|
||||
export const API_BASE_URL = resolvePublicApiBaseUrl();
|
||||
|
||||
export interface ApiPost {
|
||||
@@ -121,6 +143,7 @@ export interface CreateCommentInput {
|
||||
paragraphExcerpt?: string;
|
||||
replyTo?: string | null;
|
||||
replyToCommentId?: number | null;
|
||||
turnstileToken?: string;
|
||||
captchaToken?: string;
|
||||
captchaAnswer?: string;
|
||||
website?: string;
|
||||
@@ -141,8 +164,12 @@ export interface ApiTag {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
count?: number;
|
||||
description?: string | null;
|
||||
cover_image?: string | null;
|
||||
accent_color?: string | null;
|
||||
seo_title?: string | null;
|
||||
seo_description?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiCategory {
|
||||
@@ -150,6 +177,11 @@ export interface ApiCategory {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
description?: string | null;
|
||||
cover_image?: string | null;
|
||||
accent_color?: string | null;
|
||||
seo_title?: string | null;
|
||||
seo_description?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiFriendLink {
|
||||
@@ -230,6 +262,11 @@ export interface ApiSiteSettings {
|
||||
}> | null;
|
||||
ai_enabled: boolean;
|
||||
paragraph_comments_enabled: boolean;
|
||||
comment_turnstile_enabled: boolean;
|
||||
subscription_turnstile_enabled: boolean;
|
||||
web_push_enabled: boolean;
|
||||
turnstile_site_key: string | null;
|
||||
web_push_vapid_public_key: string | null;
|
||||
subscription_popup_enabled: boolean;
|
||||
subscription_popup_title: string | null;
|
||||
subscription_popup_description: string | null;
|
||||
@@ -326,6 +363,20 @@ export interface ApiSearchResult {
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface ApiPagedResponse<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
sort_by: string;
|
||||
sort_order: string;
|
||||
}
|
||||
|
||||
export interface ApiPagedSearchResponse extends ApiPagedResponse<ApiSearchResult> {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -401,12 +452,18 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
},
|
||||
comments: {
|
||||
paragraphsEnabled: true,
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: undefined,
|
||||
},
|
||||
subscriptions: {
|
||||
popupEnabled: true,
|
||||
popupTitle: '订阅更新',
|
||||
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
|
||||
popupDelaySeconds: 18,
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: undefined,
|
||||
webPushEnabled: false,
|
||||
webPushVapidPublicKey: undefined,
|
||||
},
|
||||
seo: {
|
||||
defaultOgImage: undefined,
|
||||
@@ -451,6 +508,12 @@ const normalizeTag = (tag: ApiTag): UiTag => ({
|
||||
id: String(tag.id),
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
count: tag.count,
|
||||
description: tag.description ?? undefined,
|
||||
coverImage: tag.cover_image ?? undefined,
|
||||
accentColor: tag.accent_color ?? undefined,
|
||||
seoTitle: tag.seo_title ?? undefined,
|
||||
seoDescription: tag.seo_description ?? undefined,
|
||||
});
|
||||
|
||||
const normalizeCategory = (category: ApiCategory): UiCategory => ({
|
||||
@@ -458,6 +521,11 @@ const normalizeCategory = (category: ApiCategory): UiCategory => ({
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
count: category.count,
|
||||
description: category.description ?? undefined,
|
||||
coverImage: category.cover_image ?? undefined,
|
||||
accentColor: category.accent_color ?? undefined,
|
||||
seoTitle: category.seo_title ?? undefined,
|
||||
seoDescription: category.seo_description ?? undefined,
|
||||
});
|
||||
|
||||
const normalizeAvatarUrl = (value: string | null | undefined) => {
|
||||
@@ -532,6 +600,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
},
|
||||
comments: {
|
||||
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
|
||||
turnstileEnabled: Boolean(settings.comment_turnstile_enabled),
|
||||
turnstileSiteKey:
|
||||
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||||
},
|
||||
subscriptions: {
|
||||
popupEnabled:
|
||||
@@ -544,6 +615,14 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
popupDelaySeconds:
|
||||
settings.subscription_popup_delay_seconds ??
|
||||
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
|
||||
turnstileEnabled: Boolean(settings.subscription_turnstile_enabled),
|
||||
turnstileSiteKey:
|
||||
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||||
webPushEnabled: Boolean(settings.web_push_enabled),
|
||||
webPushVapidPublicKey:
|
||||
settings.web_push_vapid_public_key ||
|
||||
resolvePublicWebPushVapidPublicKey() ||
|
||||
undefined,
|
||||
},
|
||||
seo: {
|
||||
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
||||
@@ -703,6 +782,46 @@ class ApiClient {
|
||||
return posts.map(normalizePost);
|
||||
}
|
||||
|
||||
async getPostsPage(options?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
category?: string;
|
||||
tag?: string;
|
||||
postType?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}): Promise<{
|
||||
items: UiPost[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.page) params.set('page', String(options.page));
|
||||
if (options?.pageSize) params.set('page_size', String(options.pageSize));
|
||||
if (options?.search) params.set('search', options.search);
|
||||
if (options?.category) params.set('category', options.category);
|
||||
if (options?.tag) params.set('tag', options.tag);
|
||||
if (options?.postType) params.set('type', options.postType);
|
||||
if (options?.sortBy) params.set('sort_by', options.sortBy);
|
||||
if (options?.sortOrder) params.set('sort_order', options.sortOrder);
|
||||
|
||||
const payload = await this.fetch<ApiPagedResponse<ApiPost>>(`/posts/page?${params.toString()}`);
|
||||
return {
|
||||
items: payload.items.map(normalizePost),
|
||||
page: payload.page,
|
||||
pageSize: payload.page_size,
|
||||
total: payload.total,
|
||||
totalPages: payload.total_pages,
|
||||
sortBy: payload.sort_by,
|
||||
sortOrder: payload.sort_order,
|
||||
};
|
||||
}
|
||||
|
||||
async getPost(id: number): Promise<UiPost> {
|
||||
const post = await this.fetch<ApiPost>(`/posts/${id}`);
|
||||
return normalizePost(post);
|
||||
@@ -782,6 +901,7 @@ class ApiClient {
|
||||
paragraphExcerpt: comment.paragraphExcerpt,
|
||||
replyTo: comment.replyTo,
|
||||
replyToCommentId: comment.replyToCommentId,
|
||||
turnstileToken: comment.turnstileToken,
|
||||
captchaToken: comment.captchaToken,
|
||||
captchaAnswer: comment.captchaAnswer,
|
||||
website: comment.website,
|
||||
@@ -955,12 +1075,87 @@ class ApiClient {
|
||||
image: result.image,
|
||||
images: null,
|
||||
pinned: result.pinned ?? false,
|
||||
status: null,
|
||||
visibility: null,
|
||||
publish_at: null,
|
||||
unpublish_at: null,
|
||||
canonical_url: null,
|
||||
noindex: null,
|
||||
og_image: null,
|
||||
redirect_from: null,
|
||||
redirect_to: null,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async searchPostsPage(options: {
|
||||
query: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
category?: string;
|
||||
tag?: string;
|
||||
postType?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}): Promise<{
|
||||
query: string;
|
||||
items: UiPost[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
}> {
|
||||
const params = new URLSearchParams({ q: options.query });
|
||||
if (options.page) params.set('page', String(options.page));
|
||||
if (options.pageSize) params.set('page_size', String(options.pageSize));
|
||||
if (options.category) params.set('category', options.category);
|
||||
if (options.tag) params.set('tag', options.tag);
|
||||
if (options.postType) params.set('type', options.postType);
|
||||
if (options.sortBy) params.set('sort_by', options.sortBy);
|
||||
if (options.sortOrder) params.set('sort_order', options.sortOrder);
|
||||
|
||||
const payload = await this.fetch<ApiPagedSearchResponse>(`/search/page?${params.toString()}`);
|
||||
return {
|
||||
query: payload.query,
|
||||
items: payload.items.map((result) =>
|
||||
normalizePost({
|
||||
id: result.id,
|
||||
title: result.title || 'Untitled',
|
||||
slug: result.slug,
|
||||
description: result.description || '',
|
||||
content: result.content || '',
|
||||
category: result.category || '',
|
||||
tags: result.tags ?? [],
|
||||
post_type: result.post_type || 'article',
|
||||
image: result.image,
|
||||
images: null,
|
||||
pinned: result.pinned ?? false,
|
||||
status: null,
|
||||
visibility: null,
|
||||
publish_at: null,
|
||||
unpublish_at: null,
|
||||
canonical_url: null,
|
||||
noindex: null,
|
||||
og_image: null,
|
||||
redirect_from: null,
|
||||
redirect_to: null,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
}),
|
||||
),
|
||||
page: payload.page,
|
||||
pageSize: payload.page_size,
|
||||
total: payload.total,
|
||||
totalPages: payload.total_pages,
|
||||
sortBy: payload.sort_by,
|
||||
sortOrder: payload.sort_order,
|
||||
};
|
||||
}
|
||||
|
||||
async askAi(question: string): Promise<AiAskResponse> {
|
||||
return this.fetch<AiAskResponse>('/ai/ask', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -37,6 +37,7 @@ export const messages = {
|
||||
cancel: '取消',
|
||||
clear: '清除',
|
||||
reset: '重置',
|
||||
refresh: '刷新',
|
||||
reply: '回复',
|
||||
like: '点赞',
|
||||
visit: '访问',
|
||||
@@ -63,6 +64,10 @@ export const messages = {
|
||||
featureOff: '功能未开启',
|
||||
emptyState: '当前还没有内容。',
|
||||
apiUnavailable: 'API 暂时不可用',
|
||||
humanVerification: '人机验证',
|
||||
turnstileHint: '提交前请先完成 Cloudflare Turnstile 校验。',
|
||||
turnstileRequired: '请先完成人机验证。',
|
||||
turnstileLoadFailed: '加载人机验证失败,请刷新页面后重试。',
|
||||
unknownError: '未知错误',
|
||||
},
|
||||
nav: {
|
||||
@@ -176,6 +181,9 @@ export const messages = {
|
||||
searchTips: '搜索会优先走站内索引,并自动复用同义词与轻量拼写纠错。',
|
||||
resultSummary: '找到 {count} 条结果',
|
||||
filteredSummary: '筛选后剩余 {count} 条结果',
|
||||
pageSummary: '第 {current} / {total} 页 · 共 {count} 条结果',
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
filtersTitle: '二次筛选',
|
||||
allCategories: '全部分类',
|
||||
allTags: '全部标签',
|
||||
@@ -478,6 +486,7 @@ export const messages = {
|
||||
cancel: 'Cancel',
|
||||
clear: 'Clear',
|
||||
reset: 'Reset',
|
||||
refresh: 'Refresh',
|
||||
reply: 'Reply',
|
||||
like: 'Like',
|
||||
visit: 'Visit',
|
||||
@@ -504,6 +513,10 @@ export const messages = {
|
||||
featureOff: 'Feature off',
|
||||
emptyState: 'Nothing here yet.',
|
||||
apiUnavailable: 'API temporarily unavailable',
|
||||
humanVerification: 'Human verification',
|
||||
turnstileHint: 'Please complete the Cloudflare Turnstile check before submitting.',
|
||||
turnstileRequired: 'Please complete the human verification first.',
|
||||
turnstileLoadFailed: 'Failed to load human verification. Refresh the page and try again.',
|
||||
unknownError: 'unknown error',
|
||||
},
|
||||
nav: {
|
||||
@@ -617,6 +630,9 @@ export const messages = {
|
||||
searchTips: 'Search uses the site index first and also applies synonyms plus lightweight typo correction automatically.',
|
||||
resultSummary: 'Found {count} results',
|
||||
filteredSummary: '{count} results after filters',
|
||||
pageSummary: 'Page {current}/{total} · {count} results',
|
||||
previous: 'Prev',
|
||||
next: 'Next',
|
||||
filtersTitle: 'Refine results',
|
||||
allCategories: 'All categories',
|
||||
allTags: 'All tags',
|
||||
|
||||
@@ -30,6 +30,10 @@ export interface Category {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
coverImage?: string;
|
||||
accentColor?: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
icon?: string;
|
||||
count?: number;
|
||||
}
|
||||
@@ -38,6 +42,11 @@ export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
coverImage?: string;
|
||||
accentColor?: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
@@ -76,12 +85,18 @@ export interface SiteSettings {
|
||||
};
|
||||
comments: {
|
||||
paragraphsEnabled: boolean;
|
||||
turnstileEnabled: boolean;
|
||||
turnstileSiteKey?: string;
|
||||
};
|
||||
subscriptions: {
|
||||
popupEnabled: boolean;
|
||||
popupTitle: string;
|
||||
popupDescription: string;
|
||||
popupDelaySeconds: number;
|
||||
turnstileEnabled: boolean;
|
||||
turnstileSiteKey?: string;
|
||||
webPushEnabled: boolean;
|
||||
webPushVapidPublicKey?: string;
|
||||
};
|
||||
seo: {
|
||||
defaultOgImage?: string;
|
||||
|
||||
110
frontend/src/lib/utils/turnstile.ts
Normal file
110
frontend/src/lib/utils/turnstile.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
const TURNSTILE_SCRIPT_ID = 'termi-turnstile-script';
|
||||
const TURNSTILE_SCRIPT_SRC =
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
|
||||
type TurnstileApi = {
|
||||
render: (
|
||||
container: HTMLElement,
|
||||
options: Record<string, unknown>,
|
||||
) => string | number;
|
||||
reset: (widgetId?: string | number) => void;
|
||||
remove?: (widgetId?: string | number) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi;
|
||||
__termiTurnstileLoader__?: Promise<TurnstileApi>;
|
||||
}
|
||||
}
|
||||
|
||||
export type MountedTurnstile = {
|
||||
reset: () => void;
|
||||
remove: () => void;
|
||||
};
|
||||
|
||||
async function loadTurnstileScript(): Promise<TurnstileApi> {
|
||||
if (window.turnstile) {
|
||||
return window.turnstile;
|
||||
}
|
||||
|
||||
if (!window.__termiTurnstileLoader__) {
|
||||
window.__termiTurnstileLoader__ = new Promise<TurnstileApi>((resolve, reject) => {
|
||||
const existing = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
|
||||
|
||||
const handleReady = () => {
|
||||
if (window.turnstile) {
|
||||
resolve(window.turnstile);
|
||||
} else {
|
||||
reject(new Error('Turnstile script loaded without API'));
|
||||
}
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
existing.addEventListener('load', handleReady, { once: true });
|
||||
existing.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('Failed to load Turnstile script')),
|
||||
{ once: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = TURNSTILE_SCRIPT_ID;
|
||||
script.src = TURNSTILE_SCRIPT_SRC;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.addEventListener('load', handleReady, { once: true });
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('Failed to load Turnstile script')),
|
||||
{ once: true },
|
||||
);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
return window.__termiTurnstileLoader__;
|
||||
}
|
||||
|
||||
export async function mountTurnstile(
|
||||
container: HTMLElement,
|
||||
options: {
|
||||
siteKey: string;
|
||||
onToken: (token: string) => void;
|
||||
onExpire?: () => void;
|
||||
onError?: () => void;
|
||||
},
|
||||
): Promise<MountedTurnstile> {
|
||||
const turnstile = await loadTurnstileScript();
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
const widgetId = turnstile.render(container, {
|
||||
sitekey: options.siteKey,
|
||||
callback: (token: string) => {
|
||||
options.onToken(token);
|
||||
},
|
||||
'expired-callback': () => {
|
||||
options.onExpire?.();
|
||||
},
|
||||
'error-callback': () => {
|
||||
options.onError?.();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
reset() {
|
||||
turnstile.reset(widgetId);
|
||||
},
|
||||
remove() {
|
||||
if (typeof turnstile.remove === 'function') {
|
||||
turnstile.remove(widgetId);
|
||||
} else {
|
||||
turnstile.reset(widgetId);
|
||||
}
|
||||
container.innerHTML = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
112
frontend/src/lib/utils/web-push.ts
Normal file
112
frontend/src/lib/utils/web-push.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
const SERVICE_WORKER_URL = '/termi-web-push-sw.js';
|
||||
|
||||
export type BrowserPushSubscriptionPayload = {
|
||||
endpoint: string;
|
||||
expirationTime?: number | null;
|
||||
keys: {
|
||||
auth: string;
|
||||
p256dh: string;
|
||||
};
|
||||
};
|
||||
|
||||
function ensureBrowserSupport() {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!('Notification' in window) ||
|
||||
!('serviceWorker' in navigator) ||
|
||||
!('PushManager' in window)
|
||||
) {
|
||||
throw new Error('当前浏览器不支持 Web Push。');
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const normalized = base64String.trim();
|
||||
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||
const base64 = (normalized + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = window.atob(base64);
|
||||
const output = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
output[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async function getRegistration() {
|
||||
ensureBrowserSupport();
|
||||
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' });
|
||||
return navigator.serviceWorker.ready;
|
||||
}
|
||||
|
||||
function normalizeSubscription(
|
||||
subscription: PushSubscription | PushSubscriptionJSON,
|
||||
): BrowserPushSubscriptionPayload {
|
||||
const json = 'toJSON' in subscription ? subscription.toJSON() : subscription;
|
||||
const endpoint = json.endpoint?.trim() || '';
|
||||
const auth = json.keys?.auth?.trim() || '';
|
||||
const p256dh = json.keys?.p256dh?.trim() || '';
|
||||
|
||||
if (!endpoint || !auth || !p256dh) {
|
||||
throw new Error('浏览器返回的 PushSubscription 不完整。');
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
expirationTime: json.expirationTime ?? null,
|
||||
keys: {
|
||||
auth,
|
||||
p256dh,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function supportsBrowserPush() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'Notification' in window &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBrowserPushSubscription() {
|
||||
if (!supportsBrowserPush()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registration = await getRegistration();
|
||||
return registration.pushManager.getSubscription();
|
||||
}
|
||||
|
||||
export async function ensureBrowserPushSubscription(
|
||||
publicKey: string,
|
||||
): Promise<BrowserPushSubscriptionPayload> {
|
||||
ensureBrowserSupport();
|
||||
|
||||
if (!publicKey.trim()) {
|
||||
throw new Error('Web Push 公钥未配置。');
|
||||
}
|
||||
|
||||
const permission =
|
||||
Notification.permission === 'granted'
|
||||
? 'granted'
|
||||
: await Notification.requestPermission();
|
||||
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('浏览器通知权限未开启。');
|
||||
}
|
||||
|
||||
const registration = await getRegistration();
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
}
|
||||
|
||||
return normalizeSubscription(subscription);
|
||||
}
|
||||
Reference in New Issue
Block a user