xiaolongmr commited on
Commit ·
dbfb96e
1
Parent(s): d99f5c3
Improve frontend loading performance
Browse files- Dockerfile +15 -0
- api/app.py +63 -4
- web/src/components/announcement-markdown.tsx +64 -0
- web/src/components/announcements-popover.tsx +10 -61
- web/src/lib/auth-session.ts +32 -0
Dockerfile
CHANGED
|
@@ -48,6 +48,21 @@ COPY services ./services
|
|
| 48 |
COPY utils ./utils
|
| 49 |
COPY scripts ./scripts
|
| 50 |
COPY --from=web-build /app/web/out ./web_dist
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
EXPOSE 7860
|
| 53 |
|
|
|
|
| 48 |
COPY utils ./utils
|
| 49 |
COPY scripts ./scripts
|
| 50 |
COPY --from=web-build /app/web/out ./web_dist
|
| 51 |
+
RUN python - <<'PY'
|
| 52 |
+
from __future__ import annotations
|
| 53 |
+
|
| 54 |
+
import gzip
|
| 55 |
+
from pathlib import Path
|
| 56 |
+
|
| 57 |
+
root = Path("web_dist")
|
| 58 |
+
suffixes = {".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"}
|
| 59 |
+
for path in root.rglob("*"):
|
| 60 |
+
if not path.is_file() or path.suffix.lower() not in suffixes or path.stat().st_size < 1024:
|
| 61 |
+
continue
|
| 62 |
+
gz_path = path.with_name(f"{path.name}.gz")
|
| 63 |
+
with path.open("rb") as source, gzip.open(gz_path, "wb", compresslevel=9) as target:
|
| 64 |
+
target.writelines(source)
|
| 65 |
+
PY
|
| 66 |
|
| 67 |
EXPOSE 7860
|
| 68 |
|
api/app.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from contextlib import asynccontextmanager
|
|
|
|
|
|
|
| 4 |
from threading import Event
|
| 5 |
|
| 6 |
-
from fastapi import FastAPI, HTTPException
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from fastapi.responses import FileResponse
|
| 9 |
|
|
@@ -13,6 +15,45 @@ from api.support import resolve_web_asset, start_limited_account_watcher
|
|
| 13 |
from services.backup_service import backup_service
|
| 14 |
from services.config import config
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
def create_app() -> FastAPI:
|
| 18 |
app_version = config.app_version
|
|
@@ -47,15 +88,33 @@ def create_app() -> FastAPI:
|
|
| 47 |
app.include_router(system.create_router(app_version))
|
| 48 |
|
| 49 |
@app.get("/{full_path:path}", include_in_schema=False)
|
| 50 |
-
async def serve_web(full_path: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
asset = resolve_web_asset(full_path)
|
| 52 |
if asset is not None:
|
| 53 |
-
|
|
|
|
| 54 |
if full_path.strip("/").startswith("_next/"):
|
| 55 |
raise HTTPException(status_code=404, detail="Not Found")
|
| 56 |
fallback = resolve_web_asset("")
|
| 57 |
if fallback is None:
|
| 58 |
raise HTTPException(status_code=404, detail="Not Found")
|
| 59 |
-
|
|
|
|
| 60 |
|
| 61 |
return app
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
+
from mimetypes import guess_type
|
| 5 |
+
from pathlib import Path
|
| 6 |
from threading import Event
|
| 7 |
|
| 8 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
from fastapi.responses import FileResponse
|
| 11 |
|
|
|
|
| 15 |
from services.backup_service import backup_service
|
| 16 |
from services.config import config
|
| 17 |
|
| 18 |
+
STATIC_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
| 19 |
+
ASSET_CACHE_CONTROL = "public, max-age=86400"
|
| 20 |
+
HTML_CACHE_CONTROL = "no-cache"
|
| 21 |
+
COMPRESSIBLE_SUFFIXES = {".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _web_cache_headers(full_path: str, asset: Path) -> dict[str, str]:
|
| 25 |
+
clean_path = full_path.strip("/")
|
| 26 |
+
suffix = asset.suffix.lower()
|
| 27 |
+
headers: dict[str, str] = {}
|
| 28 |
+
if clean_path.startswith("_next/static/"):
|
| 29 |
+
headers["Cache-Control"] = STATIC_CACHE_CONTROL
|
| 30 |
+
elif suffix in {".html", ".txt"}:
|
| 31 |
+
headers["Cache-Control"] = HTML_CACHE_CONTROL
|
| 32 |
+
else:
|
| 33 |
+
headers["Cache-Control"] = ASSET_CACHE_CONTROL
|
| 34 |
+
return headers
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _select_web_asset(request: Request, full_path: str, asset: Path) -> tuple[Path, dict[str, str], str | None]:
|
| 38 |
+
headers = _web_cache_headers(full_path, asset)
|
| 39 |
+
media_type = guess_type(str(asset))[0]
|
| 40 |
+
accept_encoding = str(request.headers.get("accept-encoding") or "").lower()
|
| 41 |
+
gzip_asset = asset.with_name(f"{asset.name}.gz")
|
| 42 |
+
is_compressible = asset.suffix.lower() in COMPRESSIBLE_SUFFIXES
|
| 43 |
+
if is_compressible:
|
| 44 |
+
headers = dict(headers)
|
| 45 |
+
headers["Vary"] = "Accept-Encoding"
|
| 46 |
+
if "gzip" in accept_encoding and is_compressible and gzip_asset.is_file():
|
| 47 |
+
headers = dict(headers)
|
| 48 |
+
headers["Content-Encoding"] = "gzip"
|
| 49 |
+
return gzip_asset, headers, media_type
|
| 50 |
+
return asset, headers, media_type
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _is_api_like_path(full_path: str) -> bool:
|
| 54 |
+
clean_path = full_path.strip("/")
|
| 55 |
+
return clean_path.startswith(("api/", "auth/", "v1/"))
|
| 56 |
+
|
| 57 |
|
| 58 |
def create_app() -> FastAPI:
|
| 59 |
app_version = config.app_version
|
|
|
|
| 88 |
app.include_router(system.create_router(app_version))
|
| 89 |
|
| 90 |
@app.get("/{full_path:path}", include_in_schema=False)
|
| 91 |
+
async def serve_web(full_path: str, request: Request):
|
| 92 |
+
asset = resolve_web_asset(full_path)
|
| 93 |
+
if asset is not None:
|
| 94 |
+
selected_asset, headers, media_type = _select_web_asset(request, full_path, asset)
|
| 95 |
+
return FileResponse(selected_asset, headers=headers, media_type=media_type)
|
| 96 |
+
if full_path.strip("/").startswith("_next/"):
|
| 97 |
+
raise HTTPException(status_code=404, detail="Not Found")
|
| 98 |
+
fallback = resolve_web_asset("")
|
| 99 |
+
if fallback is None:
|
| 100 |
+
raise HTTPException(status_code=404, detail="Not Found")
|
| 101 |
+
selected_asset, headers, media_type = _select_web_asset(request, "", fallback)
|
| 102 |
+
return FileResponse(selected_asset, headers=headers, media_type=media_type)
|
| 103 |
+
|
| 104 |
+
@app.head("/{full_path:path}", include_in_schema=False)
|
| 105 |
+
async def serve_web_head(full_path: str, request: Request):
|
| 106 |
+
if _is_api_like_path(full_path):
|
| 107 |
+
raise HTTPException(status_code=405, detail="Method Not Allowed")
|
| 108 |
asset = resolve_web_asset(full_path)
|
| 109 |
if asset is not None:
|
| 110 |
+
selected_asset, headers, media_type = _select_web_asset(request, full_path, asset)
|
| 111 |
+
return FileResponse(selected_asset, headers=headers, media_type=media_type)
|
| 112 |
if full_path.strip("/").startswith("_next/"):
|
| 113 |
raise HTTPException(status_code=404, detail="Not Found")
|
| 114 |
fallback = resolve_web_asset("")
|
| 115 |
if fallback is None:
|
| 116 |
raise HTTPException(status_code=404, detail="Not Found")
|
| 117 |
+
selected_asset, headers, media_type = _select_web_asset(request, "", fallback)
|
| 118 |
+
return FileResponse(selected_asset, headers=headers, media_type=media_type)
|
| 119 |
|
| 120 |
return app
|
web/src/components/announcement-markdown.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import ReactMarkdown from "react-markdown";
|
| 4 |
+
import remarkBreaks from "remark-breaks";
|
| 5 |
+
import remarkGfm from "remark-gfm";
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils";
|
| 8 |
+
|
| 9 |
+
export function AnnouncementMarkdown({ content, compact = false }: { content: string; compact?: boolean }) {
|
| 10 |
+
return (
|
| 11 |
+
<div
|
| 12 |
+
className={cn(
|
| 13 |
+
"space-y-2 text-sm leading-6 text-stone-700",
|
| 14 |
+
compact && "space-y-1",
|
| 15 |
+
"[&_input[type=checkbox]]:mr-2 [&_input[type=checkbox]]:align-middle",
|
| 16 |
+
)}
|
| 17 |
+
>
|
| 18 |
+
<ReactMarkdown
|
| 19 |
+
remarkPlugins={[remarkGfm, remarkBreaks]}
|
| 20 |
+
skipHtml
|
| 21 |
+
components={{
|
| 22 |
+
h1: ({ children }) => <h2 className={cn("font-semibold text-stone-950", compact ? "text-base" : "text-lg")}>{children}</h2>,
|
| 23 |
+
h2: ({ children }) => <h3 className={cn("font-semibold text-stone-950", compact ? "text-sm" : "text-base")}>{children}</h3>,
|
| 24 |
+
h3: ({ children }) => <h4 className="text-sm font-semibold text-stone-900">{children}</h4>,
|
| 25 |
+
h4: ({ children }) => <h5 className="text-sm font-semibold text-stone-900">{children}</h5>,
|
| 26 |
+
p: ({ children }) => <p>{children}</p>,
|
| 27 |
+
a: ({ children, href }) => (
|
| 28 |
+
<a href={href || "#"} target="_blank" rel="noreferrer" className="font-medium text-sky-700 underline underline-offset-2">
|
| 29 |
+
{children}
|
| 30 |
+
</a>
|
| 31 |
+
),
|
| 32 |
+
ul: ({ children, className }) => (
|
| 33 |
+
<ul className={cn("ml-5 space-y-1", className?.includes("contains-task-list") ? "list-none" : "list-disc")}>{children}</ul>
|
| 34 |
+
),
|
| 35 |
+
ol: ({ children }) => <ol className="ml-5 list-decimal space-y-1">{children}</ol>,
|
| 36 |
+
li: ({ children, className }) => <li className={cn("pl-1", className?.includes("task-list-item") && "-ml-5 list-none pl-0")}>{children}</li>,
|
| 37 |
+
blockquote: ({ children }) => <blockquote className="border-l-2 border-stone-300 pl-3 text-stone-500">{children}</blockquote>,
|
| 38 |
+
code: ({ children, className }) => {
|
| 39 |
+
const isBlock = String(className || "").includes("language-");
|
| 40 |
+
return isBlock ? (
|
| 41 |
+
<code className="block overflow-x-auto rounded-xl bg-stone-100 p-3 font-mono text-xs leading-5 text-stone-800">{children}</code>
|
| 42 |
+
) : (
|
| 43 |
+
<code className="rounded bg-stone-100 px-1 py-0.5 font-mono text-xs text-stone-800">{children}</code>
|
| 44 |
+
);
|
| 45 |
+
},
|
| 46 |
+
pre: ({ children }) => <pre className="overflow-x-auto">{children}</pre>,
|
| 47 |
+
table: ({ children }) => (
|
| 48 |
+
<div className="overflow-x-auto rounded-xl border border-stone-200">
|
| 49 |
+
<table className="w-full text-left text-xs">{children}</table>
|
| 50 |
+
</div>
|
| 51 |
+
),
|
| 52 |
+
th: ({ children }) => <th className="border-b border-stone-200 bg-stone-50 px-3 py-2 font-semibold text-stone-700">{children}</th>,
|
| 53 |
+
td: ({ children }) => <td className="border-b border-stone-100 px-3 py-2 text-stone-600">{children}</td>,
|
| 54 |
+
hr: () => <hr className="border-stone-200" />,
|
| 55 |
+
img: ({ src, alt }) => (
|
| 56 |
+
<img src={src || ""} alt={alt || ""} className="w-full rounded-xl border border-stone-100 object-cover" loading="lazy" />
|
| 57 |
+
),
|
| 58 |
+
}}
|
| 59 |
+
>
|
| 60 |
+
{content || ""}
|
| 61 |
+
</ReactMarkdown>
|
| 62 |
+
</div>
|
| 63 |
+
);
|
| 64 |
+
}
|
web/src/components/announcements-popover.tsx
CHANGED
|
@@ -2,9 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useMemo, useState } from "react";
|
| 4 |
import { Bell, LoaderCircle, Megaphone } from "lucide-react";
|
| 5 |
-
import
|
| 6 |
-
import remarkBreaks from "remark-breaks";
|
| 7 |
-
import remarkGfm from "remark-gfm";
|
| 8 |
|
| 9 |
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
| 10 |
import { fetchAnnouncements, type Announcement, type AnnouncementType } from "@/lib/api";
|
|
@@ -13,6 +11,13 @@ import { cn } from "@/lib/utils";
|
|
| 13 |
import type { StoredAuthSession } from "@/store/auth";
|
| 14 |
|
| 15 |
const READ_STORAGE_PREFIX = "chatgpt2api:announcements:read:";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
function getAnnouncementStamp(item: Announcement) {
|
| 18 |
return `${item.id}:${item.updated_at || item.created_at || ""}`;
|
|
@@ -208,7 +213,7 @@ export function AnnouncementsPopover({ session }: { session: StoredAuthSession }
|
|
| 208 |
) : tab === "notice" ? (
|
| 209 |
<div className="space-y-5">
|
| 210 |
{visibleItems.map((item) => (
|
| 211 |
-
<
|
| 212 |
))}
|
| 213 |
</div>
|
| 214 |
) : (
|
|
@@ -224,7 +229,7 @@ export function AnnouncementsPopover({ session }: { session: StoredAuthSession }
|
|
| 224 |
)}
|
| 225 |
aria-hidden="true"
|
| 226 |
/>
|
| 227 |
-
<
|
| 228 |
<div className="mt-2 text-xs leading-5 text-stone-400">{formatTimelineTime(item.updated_at || item.created_at)}</div>
|
| 229 |
</article>
|
| 230 |
);
|
|
@@ -246,59 +251,3 @@ export function AnnouncementsPopover({ session }: { session: StoredAuthSession }
|
|
| 246 |
);
|
| 247 |
}
|
| 248 |
|
| 249 |
-
function MarkdownBlock({ content, compact = false }: { content: string; compact?: boolean }) {
|
| 250 |
-
return (
|
| 251 |
-
<div
|
| 252 |
-
className={cn(
|
| 253 |
-
"space-y-2 text-sm leading-6 text-stone-700",
|
| 254 |
-
compact && "space-y-1",
|
| 255 |
-
"[&_input[type=checkbox]]:mr-2 [&_input[type=checkbox]]:align-middle",
|
| 256 |
-
)}
|
| 257 |
-
>
|
| 258 |
-
<ReactMarkdown
|
| 259 |
-
remarkPlugins={[remarkGfm, remarkBreaks]}
|
| 260 |
-
skipHtml
|
| 261 |
-
components={{
|
| 262 |
-
h1: ({ children }) => <h2 className={cn("font-semibold text-stone-950", compact ? "text-base" : "text-lg")}>{children}</h2>,
|
| 263 |
-
h2: ({ children }) => <h3 className={cn("font-semibold text-stone-950", compact ? "text-sm" : "text-base")}>{children}</h3>,
|
| 264 |
-
h3: ({ children }) => <h4 className="text-sm font-semibold text-stone-900">{children}</h4>,
|
| 265 |
-
h4: ({ children }) => <h5 className="text-sm font-semibold text-stone-900">{children}</h5>,
|
| 266 |
-
p: ({ children }) => <p>{children}</p>,
|
| 267 |
-
a: ({ children, href }) => (
|
| 268 |
-
<a href={href || "#"} target="_blank" rel="noreferrer" className="font-medium text-sky-700 underline underline-offset-2">
|
| 269 |
-
{children}
|
| 270 |
-
</a>
|
| 271 |
-
),
|
| 272 |
-
ul: ({ children, className }) => (
|
| 273 |
-
<ul className={cn("ml-5 space-y-1", className?.includes("contains-task-list") ? "list-none" : "list-disc")}>{children}</ul>
|
| 274 |
-
),
|
| 275 |
-
ol: ({ children }) => <ol className="ml-5 list-decimal space-y-1">{children}</ol>,
|
| 276 |
-
li: ({ children, className }) => <li className={cn("pl-1", className?.includes("task-list-item") && "-ml-5 list-none pl-0")}>{children}</li>,
|
| 277 |
-
blockquote: ({ children }) => <blockquote className="border-l-2 border-stone-300 pl-3 text-stone-500">{children}</blockquote>,
|
| 278 |
-
code: ({ children, className }) => {
|
| 279 |
-
const isBlock = String(className || "").includes("language-");
|
| 280 |
-
return isBlock ? (
|
| 281 |
-
<code className="block overflow-x-auto rounded-xl bg-stone-100 p-3 font-mono text-xs leading-5 text-stone-800">{children}</code>
|
| 282 |
-
) : (
|
| 283 |
-
<code className="rounded bg-stone-100 px-1 py-0.5 font-mono text-xs text-stone-800">{children}</code>
|
| 284 |
-
);
|
| 285 |
-
},
|
| 286 |
-
pre: ({ children }) => <pre className="overflow-x-auto">{children}</pre>,
|
| 287 |
-
table: ({ children }) => (
|
| 288 |
-
<div className="overflow-x-auto rounded-xl border border-stone-200">
|
| 289 |
-
<table className="w-full text-left text-xs">{children}</table>
|
| 290 |
-
</div>
|
| 291 |
-
),
|
| 292 |
-
th: ({ children }) => <th className="border-b border-stone-200 bg-stone-50 px-3 py-2 font-semibold text-stone-700">{children}</th>,
|
| 293 |
-
td: ({ children }) => <td className="border-b border-stone-100 px-3 py-2 text-stone-600">{children}</td>,
|
| 294 |
-
hr: () => <hr className="border-stone-200" />,
|
| 295 |
-
img: ({ src, alt }) => (
|
| 296 |
-
<img src={src || ""} alt={alt || ""} className="w-full rounded-xl border border-stone-100 object-cover" loading="lazy" />
|
| 297 |
-
),
|
| 298 |
-
}}
|
| 299 |
-
>
|
| 300 |
-
{content || ""}
|
| 301 |
-
</ReactMarkdown>
|
| 302 |
-
</div>
|
| 303 |
-
);
|
| 304 |
-
}
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useMemo, useState } from "react";
|
| 4 |
import { Bell, LoaderCircle, Megaphone } from "lucide-react";
|
| 5 |
+
import dynamic from "next/dynamic";
|
|
|
|
|
|
|
| 6 |
|
| 7 |
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
| 8 |
import { fetchAnnouncements, type Announcement, type AnnouncementType } from "@/lib/api";
|
|
|
|
| 11 |
import type { StoredAuthSession } from "@/store/auth";
|
| 12 |
|
| 13 |
const READ_STORAGE_PREFIX = "chatgpt2api:announcements:read:";
|
| 14 |
+
const AnnouncementMarkdown = dynamic(
|
| 15 |
+
() => import("@/components/announcement-markdown").then((module) => module.AnnouncementMarkdown),
|
| 16 |
+
{
|
| 17 |
+
ssr: false,
|
| 18 |
+
loading: () => <div className="h-16 animate-pulse rounded-xl bg-stone-100" />,
|
| 19 |
+
},
|
| 20 |
+
);
|
| 21 |
|
| 22 |
function getAnnouncementStamp(item: Announcement) {
|
| 23 |
return `${item.id}:${item.updated_at || item.created_at || ""}`;
|
|
|
|
| 213 |
) : tab === "notice" ? (
|
| 214 |
<div className="space-y-5">
|
| 215 |
{visibleItems.map((item) => (
|
| 216 |
+
<AnnouncementMarkdown key={item.id} content={item.content} />
|
| 217 |
))}
|
| 218 |
</div>
|
| 219 |
) : (
|
|
|
|
| 229 |
)}
|
| 230 |
aria-hidden="true"
|
| 231 |
/>
|
| 232 |
+
<AnnouncementMarkdown content={item.content} compact />
|
| 233 |
<div className="mt-2 text-xs leading-5 text-stone-400">{formatTimelineTime(item.updated_at || item.created_at)}</div>
|
| 234 |
</article>
|
| 235 |
);
|
|
|
|
| 251 |
);
|
| 252 |
}
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
web/src/lib/auth-session.ts
CHANGED
|
@@ -3,6 +3,12 @@
|
|
| 3 |
import { login } from "@/lib/api";
|
| 4 |
import { clearStoredAuthSession, getStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
function isUnauthorizedError(error: unknown) {
|
| 7 |
if (!error || typeof error !== "object") {
|
| 8 |
return false;
|
|
@@ -14,9 +20,29 @@ function isUnauthorizedError(error: unknown) {
|
|
| 14 |
export async function getValidatedAuthSession(): Promise<StoredAuthSession | null> {
|
| 15 |
const storedSession = await getStoredAuthSession();
|
| 16 |
if (!storedSession) {
|
|
|
|
|
|
|
| 17 |
return null;
|
| 18 |
}
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
try {
|
| 21 |
const data = await login(storedSession.key);
|
| 22 |
const nextSession: StoredAuthSession = {
|
|
@@ -27,12 +53,18 @@ export async function getValidatedAuthSession(): Promise<StoredAuthSession | nul
|
|
| 27 |
wechatOpenid: storedSession.wechatOpenid,
|
| 28 |
};
|
| 29 |
await setStoredAuthSession(nextSession);
|
|
|
|
|
|
|
| 30 |
return nextSession;
|
| 31 |
} catch (error) {
|
| 32 |
if (isUnauthorizedError(error)) {
|
| 33 |
await clearStoredAuthSession();
|
|
|
|
|
|
|
| 34 |
return null;
|
| 35 |
}
|
|
|
|
|
|
|
| 36 |
return storedSession;
|
| 37 |
}
|
| 38 |
}
|
|
|
|
| 3 |
import { login } from "@/lib/api";
|
| 4 |
import { clearStoredAuthSession, getStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
|
| 5 |
|
| 6 |
+
const VALIDATION_CACHE_MS = 10_000;
|
| 7 |
+
let cachedSession: StoredAuthSession | null = null;
|
| 8 |
+
let cachedAt = 0;
|
| 9 |
+
let validationPromise: Promise<StoredAuthSession | null> | null = null;
|
| 10 |
+
let validationKey = "";
|
| 11 |
+
|
| 12 |
function isUnauthorizedError(error: unknown) {
|
| 13 |
if (!error || typeof error !== "object") {
|
| 14 |
return false;
|
|
|
|
| 20 |
export async function getValidatedAuthSession(): Promise<StoredAuthSession | null> {
|
| 21 |
const storedSession = await getStoredAuthSession();
|
| 22 |
if (!storedSession) {
|
| 23 |
+
cachedSession = null;
|
| 24 |
+
cachedAt = 0;
|
| 25 |
return null;
|
| 26 |
}
|
| 27 |
|
| 28 |
+
const now = Date.now();
|
| 29 |
+
if (cachedSession?.key === storedSession.key && now - cachedAt < VALIDATION_CACHE_MS) {
|
| 30 |
+
return cachedSession;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
if (validationPromise && validationKey === storedSession.key) {
|
| 34 |
+
return validationPromise;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
validationKey = storedSession.key;
|
| 38 |
+
validationPromise = validateStoredSession(storedSession).finally(() => {
|
| 39 |
+
validationPromise = null;
|
| 40 |
+
validationKey = "";
|
| 41 |
+
});
|
| 42 |
+
return validationPromise;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
async function validateStoredSession(storedSession: StoredAuthSession): Promise<StoredAuthSession | null> {
|
| 46 |
try {
|
| 47 |
const data = await login(storedSession.key);
|
| 48 |
const nextSession: StoredAuthSession = {
|
|
|
|
| 53 |
wechatOpenid: storedSession.wechatOpenid,
|
| 54 |
};
|
| 55 |
await setStoredAuthSession(nextSession);
|
| 56 |
+
cachedSession = nextSession;
|
| 57 |
+
cachedAt = Date.now();
|
| 58 |
return nextSession;
|
| 59 |
} catch (error) {
|
| 60 |
if (isUnauthorizedError(error)) {
|
| 61 |
await clearStoredAuthSession();
|
| 62 |
+
cachedSession = null;
|
| 63 |
+
cachedAt = 0;
|
| 64 |
return null;
|
| 65 |
}
|
| 66 |
+
cachedSession = storedSession;
|
| 67 |
+
cachedAt = Date.now();
|
| 68 |
return storedSession;
|
| 69 |
}
|
| 70 |
}
|