Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

extract shared data

+206 -135
+1 -1
Dockerfile
··· 4 4 COPY web/package.json web/package-lock.json ./ 5 5 RUN npm ci 6 6 COPY web/ . 7 - COPY core/atproto_apps.json /repo/core/atproto_apps.json 7 + COPY data/shared.json /repo/data/shared.json 8 8 RUN npm run build 9 9 10 10 FROM nginx:alpine
+1
Dockerfile.telnet
··· 4 4 RUN pip install --no-cache-dir httpx 5 5 COPY core/ core/ 6 6 COPY telnet/ telnet/ 7 + COPY data/ data/ 7 8 EXPOSE 2323 8 9 CMD ["python", "-m", "telnet.server"]
-10
core/atproto_apps.json
··· 1 - [ 2 - { "name": "Blacksky", "url": "https://blacksky.community" }, 3 - { "name": "Bluesky", "url": "https://bsky.app" }, 4 - { "name": "Grain Social", "url": "https://grain.social" }, 5 - { "name": "Leaflet", "url": "https://leaflet.pub" }, 6 - { "name": "pckt.blog", "url": "https://pckt.blog" }, 7 - { "name": "Streamplace", "url": "https://stream.place" }, 8 - { "name": "Tangled", "url": "https://tangled.sh" }, 9 - { "name": "wisp.place", "url": "https://wisp.place" } 10 - ]
+2 -4
core/atproto_apps.py
··· 1 1 """Shared AT Protocol apps list — used by both the TUI and the web frontend.""" 2 2 3 - import json 4 3 import random 5 - from pathlib import Path 6 4 7 - _JSON_PATH = Path(__file__).parent / "atproto_apps.json" 5 + from core.shared import ATPROTO_APPS 8 6 9 - ATPROTO_APPS: list[dict[str, str]] = json.loads(_JSON_PATH.read_text()) 7 + __all__ = ["ATPROTO_APPS", "pick_random_apps"] 10 8 11 9 12 10 def pick_random_apps(count: int) -> list[dict[str, str]]:
+2 -1
core/constellation.py
··· 2 2 3 3 from core import lexicon 4 4 from core.models import BacklinkRef, BacklinksResponse 5 + from core.shared import SERVICES 5 6 6 - BASE_URL = "https://constellation.microcosm.blue/xrpc" 7 + BASE_URL = SERVICES["constellation"] 7 8 8 9 9 10 async def get_backlinks(
+12 -8
core/lexicon.py
··· 1 1 """AT Protocol collection names for atbbs lexicons.""" 2 2 3 - SITE = "xyz.atbbs.site" 4 - BOARD = "xyz.atbbs.board" 5 - POST = "xyz.atbbs.post" 6 - BAN = "xyz.atbbs.ban" 7 - HIDE = "xyz.atbbs.hide" 8 - PIN = "xyz.atbbs.pin" 9 - PROFILE = "xyz.atbbs.profile" 3 + from core.shared import LEXICON_COLLECTIONS, OAUTH_BASE_SCOPES 10 4 11 - OAUTH_SCOPE = f"atproto blob:*/* repo:{SITE} repo:{BOARD} repo:{POST} repo:{BAN} repo:{HIDE} repo:{PIN} repo:{PROFILE}" 5 + SITE = LEXICON_COLLECTIONS["site"] 6 + BOARD = LEXICON_COLLECTIONS["board"] 7 + POST = LEXICON_COLLECTIONS["post"] 8 + BAN = LEXICON_COLLECTIONS["ban"] 9 + HIDE = LEXICON_COLLECTIONS["hide"] 10 + PIN = LEXICON_COLLECTIONS["pin"] 11 + PROFILE = LEXICON_COLLECTIONS["profile"] 12 + 13 + OAUTH_SCOPE = " ".join( 14 + [*OAUTH_BASE_SCOPES, *(f"repo:{nsid}" for nsid in LEXICON_COLLECTIONS.values())] 15 + )
+33
core/shared.py
··· 1 + """Loader for the cross-language shared data file (data/shared.json). 2 + 3 + Dev checkout: reads from <repo>/data/shared.json. 4 + Installed wheel: reads from core/_shared.json (bundled via hatch force-include). 5 + """ 6 + 7 + import json 8 + from pathlib import Path 9 + from typing import Any 10 + 11 + 12 + def _find_shared_json() -> Path: 13 + here = Path(__file__).resolve().parent 14 + candidates = [ 15 + here / "_shared.json", 16 + here.parent / "data" / "shared.json", 17 + ] 18 + for path in candidates: 19 + if path.exists(): 20 + return path 21 + raise FileNotFoundError( 22 + f"shared data file not found; looked in: {', '.join(str(p) for p in candidates)}" 23 + ) 24 + 25 + 26 + _DATA: dict[str, Any] = json.loads(_find_shared_json().read_text()) 27 + 28 + ATPROTO_APPS: list[dict[str, str]] = _DATA["atproto_apps"] 29 + LEXICON_COLLECTIONS: dict[str, str] = _DATA["lexicon_collections"] 30 + OAUTH_BASE_SCOPES: list[str] = _DATA["oauth_base_scopes"] 31 + SERVICES: dict[str, str] = _DATA["services"] 32 + DEFAULT_BOARD: dict[str, str] = _DATA["default_board"] 33 + HANDLE_PLACEHOLDERS: list[str] = _DATA["handle_placeholders"]
+2 -1
core/slingshot.py
··· 4 4 5 5 from core.cache import TTLCache 6 6 from core.models import AtUri, BacklinkRef, MiniDoc, Record 7 + from core.shared import SERVICES 7 8 8 - BASE_URL = "https://slingshot.microcosm.blue/xrpc" 9 + BASE_URL = SERVICES["slingshot"] 9 10 10 11 _identity_cache = TTLCache(ttl_seconds=300) # 5 minutes 11 12
+42
data/shared.json
··· 1 + { 2 + "atproto_apps": [ 3 + { "name": "Blacksky", "url": "https://blacksky.community" }, 4 + { "name": "Bluesky", "url": "https://bsky.app" }, 5 + { "name": "Grain Social", "url": "https://grain.social" }, 6 + { "name": "Leaflet", "url": "https://leaflet.pub" }, 7 + { "name": "pckt.blog", "url": "https://pckt.blog" }, 8 + { "name": "Streamplace", "url": "https://stream.place" }, 9 + { "name": "Tangled", "url": "https://tangled.sh" }, 10 + { "name": "wisp.place", "url": "https://wisp.place" } 11 + ], 12 + "lexicon_collections": { 13 + "site": "xyz.atbbs.site", 14 + "board": "xyz.atbbs.board", 15 + "post": "xyz.atbbs.post", 16 + "ban": "xyz.atbbs.ban", 17 + "hide": "xyz.atbbs.hide", 18 + "pin": "xyz.atbbs.pin", 19 + "profile": "xyz.atbbs.profile" 20 + }, 21 + "oauth_base_scopes": ["atproto", "blob:*/*"], 22 + "services": { 23 + "slingshot": "https://slingshot.microcosm.blue/xrpc", 24 + "constellation": "https://constellation.microcosm.blue/xrpc", 25 + "lightrail": "https://lightrail.microcosm.blue/xrpc" 26 + }, 27 + "default_board": { 28 + "slug": "general", 29 + "name": "General", 30 + "description": "Whatever's on your mind." 31 + }, 32 + "handle_placeholders": [ 33 + "handle.blacksky.app", 34 + "handle.bsky.social", 35 + "handle.eurosky.social", 36 + "handle.northsky.social", 37 + "handle.selfhosted.social", 38 + "handle.tngl.sh", 39 + "handle.pds.witchcraft.systems", 40 + "handle.your-domain.com" 41 + ] 42 + }
+3
pyproject.toml
··· 24 24 [tool.hatch.build.targets.wheel] 25 25 packages = ["cli", "core", "telnet", "tui"] 26 26 27 + [tool.hatch.build.targets.wheel.force-include] 28 + "data/shared.json" = "core/_shared.json" 29 + 27 30 [dependency-groups] 28 31 dev = []
+2 -1
tui/screens/home.py
··· 13 13 from core import lexicon 14 14 from core.models import BBSNotFoundError, NetworkError, NoBBSError 15 15 from core.resolver import resolve_bbs 16 + from core.shared import SERVICES 16 17 from core.slingshot import get_record, resolve_identities_batch 17 18 from tui.screens.site import SiteScreen 18 19 ··· 137 138 client = self.app.http_client 138 139 try: 139 140 resp = await client.get( 140 - "https://lightrail.microcosm.blue/xrpc/com.atproto.sync.listReposByCollection", 141 + f"{SERVICES['lightrail']}/com.atproto.sync.listReposByCollection", 141 142 params={"collection": lexicon.SITE, "limit": 50}, 142 143 ) 143 144 if resp.status_code != 200:
+2 -6
tui/screens/sysop/bbs_form.py
··· 9 9 from textual.widgets import Input, Static, TextArea 10 10 11 11 from core import limits 12 + from core.shared import DEFAULT_BOARD as _DEFAULT_BOARD 12 13 from core.util import now_iso 13 14 14 - DEFAULT_BOARD = { 15 - "slug": "general", 16 - "name": "General", 17 - "description": "Whatever's on your mind.", 18 - "created_at": "", 19 - } 15 + DEFAULT_BOARD = {**_DEFAULT_BOARD, "created_at": ""} 20 16 21 17 22 18 class BBSFormMixin:
+1 -10
tui/widgets/handle_input.py
··· 2 2 3 3 from textual.widgets import Input 4 4 5 - PLACEHOLDERS = [ 6 - "handle.blacksky.app", 7 - "handle.bsky.social", 8 - "handle.eurosky.social", 9 - "handle.northsky.social", 10 - "handle.selfhosted.social", 11 - "handle.tngl.sh", 12 - "handle.pds.witchcraft.systems", 13 - "handle.your-domain.com", 14 - ] 5 + from core.shared import HANDLE_PLACEHOLDERS as PLACEHOLDERS 15 6 16 7 17 8 class HandleInput(Input):
+7 -25
web/docker-entrypoint.sh
··· 6 6 # Strip trailing slash. 7 7 PUBLIC_URL="${PUBLIC_URL%/}" 8 8 9 - SCOPE="atproto blob:*/* repo:xyz.atbbs.site repo:xyz.atbbs.board repo:xyz.atbbs.post repo:xyz.atbbs.ban repo:xyz.atbbs.hide repo:xyz.atbbs.pin repo:xyz.atbbs.profile" 10 - 11 - # Runtime config read by the SPA at startup. 12 - cat > /usr/share/nginx/html/config.json <<EOF 13 - { 14 - "client_id": "${PUBLIC_URL}/client-metadata.json", 15 - "redirect_uri": "${PUBLIC_URL}/oauth/callback", 16 - "scope": "${SCOPE}" 17 - } 18 - EOF 9 + HTML=/usr/share/nginx/html 19 10 20 - # OAuth client metadata — fetched cross-origin by atproto auth servers. 21 - cat > /usr/share/nginx/html/client-metadata.json <<EOF 22 - { 23 - "client_id": "${PUBLIC_URL}/client-metadata.json", 24 - "client_name": "atbbs", 25 - "client_uri": "${PUBLIC_URL}", 26 - "redirect_uris": ["${PUBLIC_URL}/oauth/callback"], 27 - "scope": "${SCOPE}", 28 - "grant_types": ["authorization_code", "refresh_token"], 29 - "response_types": ["code"], 30 - "token_endpoint_auth_method": "none", 31 - "application_type": "web", 32 - "dpop_bound_access_tokens": true 33 - } 34 - EOF 11 + # Substitute __PUBLIC_URL__ in the vite-emitted templates. Use | as sed 12 + # delimiter since PUBLIC_URL contains /. 13 + sed "s|__PUBLIC_URL__|${PUBLIC_URL}|g" \ 14 + "${HTML}/config.template.json" > "${HTML}/config.json" 15 + sed "s|__PUBLIC_URL__|${PUBLIC_URL}|g" \ 16 + "${HTML}/client-metadata.template.json" > "${HTML}/client-metadata.json" 35 17 36 18 exec nginx -g 'daemon off;'
+1 -11
web/src/components/form/HandleInput.tsx
··· 1 1 import { useEffect, useState, type InputHTMLAttributes } from "react"; 2 + import { HANDLE_PLACEHOLDERS as PLACEHOLDERS } from "../../lib/shared"; 2 3 import { inputStyles } from "./Form"; 3 - 4 - const PLACEHOLDERS = [ 5 - "handle.blacksky.app", 6 - "handle.bsky.social", 7 - "handle.eurosky.social", 8 - "handle.northsky.social", 9 - "handle.selfhosted.social", 10 - "handle.tngl.sh", 11 - "handle.pds.witchcraft.systems", 12 - "handle.your-domain.com", 13 - ]; 14 4 15 5 interface HandleInputProps extends Omit< 16 6 InputHTMLAttributes<HTMLInputElement>,
+2 -1
web/src/hooks/useDiscovery.ts
··· 4 4 import { TTLCache } from "../lib/cache"; 5 5 import { getRecord, resolveIdentitiesBatch } from "../lib/atproto"; 6 6 import { SITE } from "../lib/lexicon"; 7 + import { SERVICES } from "../lib/shared"; 7 8 import { is } from "@atcute/lexicons/validations"; 8 9 import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 9 10 import type { XyzAtbbsSite } from "../lexicons"; ··· 33 34 (async () => { 34 35 try { 35 36 const response = await fetch( 36 - `https://lightrail.microcosm.blue/xrpc/com.atproto.sync.listReposByCollection?collection=${SITE}&limit=50`, 37 + `${SERVICES.lightrail}/com.atproto.sync.listReposByCollection?collection=${SITE}&limit=50`, 37 38 ); 38 39 const data = (await response.json()) as { repos: LightrailRepo[] }; 39 40 if (!data.repos.length) return;
+3 -2
web/src/lib/atproto.ts
··· 1 1 /** Read-side wrappers for Slingshot and Constellation (no auth needed). */ 2 2 3 3 import { TTLCache } from "./cache"; 4 + import { SERVICES } from "./shared"; 4 5 import { parseAtUri } from "./util"; 5 6 6 - const SLINGSHOT = "https://slingshot.microcosm.blue/xrpc"; 7 - const CONSTELLATION = "https://constellation.microcosm.blue/xrpc"; 7 + const SLINGSHOT = SERVICES.slingshot; 8 + const CONSTELLATION = SERVICES.constellation; 8 9 9 10 export interface MiniDoc { 10 11 did: string;
+2 -10
web/src/lib/atprotoApps.ts
··· 1 - // App list shared with the Python TUI (see core/atproto_apps.py). 2 - import apps from "../../../core/atproto_apps.json"; 3 - 4 - export interface AtprotoApp { 5 - name: string; 6 - url: string; 7 - } 8 - 9 - export const ATPROTO_APPS: AtprotoApp[] = apps; 1 + export { ATPROTO_APPS, type AtprotoApp } from "./shared"; 2 + import { ATPROTO_APPS, type AtprotoApp } from "./shared"; 10 3 11 4 export function pickRandomApps(count: number): AtprotoApp[] { 12 - // Shuffle a copy of the list and take the first `count` entries. 13 5 const shuffled = [...ATPROTO_APPS].sort(() => Math.random() - 0.5); 14 6 return shuffled.slice(0, count); 15 7 }
+35
web/src/lib/shared.ts
··· 1 + import shared from "../../../data/shared.json"; 2 + 3 + export interface AtprotoApp { 4 + name: string; 5 + url: string; 6 + } 7 + 8 + export interface LexiconCollections { 9 + site: string; 10 + board: string; 11 + post: string; 12 + ban: string; 13 + hide: string; 14 + pin: string; 15 + profile: string; 16 + } 17 + 18 + export interface Services { 19 + slingshot: string; 20 + constellation: string; 21 + lightrail: string; 22 + } 23 + 24 + export interface DefaultBoard { 25 + slug: string; 26 + name: string; 27 + description: string; 28 + } 29 + 30 + export const ATPROTO_APPS = shared.atproto_apps as AtprotoApp[]; 31 + export const LEXICON_COLLECTIONS = 32 + shared.lexicon_collections as LexiconCollections; 33 + export const SERVICES = shared.services as Services; 34 + export const DEFAULT_BOARD = shared.default_board as DefaultBoard; 35 + export const HANDLE_PLACEHOLDERS = shared.handle_placeholders as string[];
+4 -3
web/src/pages/SysopCreate.tsx
··· 3 3 import { useAuth } from "../lib/auth"; 4 4 import { putBoard, putSite } from "../lib/writes"; 5 5 import { BOARD } from "../lib/lexicon"; 6 + import { DEFAULT_BOARD } from "../lib/shared"; 6 7 import { makeAtUri, nowIso } from "../lib/util"; 7 8 import * as limits from "../lib/limits"; 8 9 import { usePageTitle } from "../hooks/usePageTitle"; ··· 22 23 const [intro, setIntro] = useState(""); 23 24 const [boards, setBoards] = useState<BoardRow[]>([ 24 25 { 25 - slug: "general", 26 - name: "General", 27 - desc: "Whatever's on your mind.", 26 + slug: DEFAULT_BOARD.slug, 27 + name: DEFAULT_BOARD.name, 28 + desc: DEFAULT_BOARD.description, 28 29 }, 29 30 ]); 30 31 const [error, setError] = useState<string | null>(null);
+1 -1
web/tsconfig.tsbuildinfo
··· 1 - {"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/activitylist.tsx","./src/components/atprotoappscard.tsx","./src/components/bbspanel.tsx","./src/components/dialbbs.tsx","./src/components/discoverylist.tsx","./src/components/errorpage.tsx","./src/components/localtime.tsx","./src/components/loginmodal.tsx","./src/components/mythreadlist.tsx","./src/components/pinbutton.tsx","./src/components/pinnedlist.tsx","./src/components/form/boardroweditor.tsx","./src/components/form/composeform.tsx","./src/components/form/filechips.tsx","./src/components/form/form.tsx","./src/components/form/handleinput.tsx","./src/components/form/handlesuggestions.tsx","./src/components/form/loginform.tsx","./src/components/layout/footer.tsx","./src/components/layout/header.tsx","./src/components/layout/headerbreadcrumbs.tsx","./src/components/layout/layout.tsx","./src/components/layout/logo.tsx","./src/components/layout/mobilebackbutton.tsx","./src/components/layout/mobilemenu.tsx","./src/components/nav/actionbar.tsx","./src/components/nav/actionbutton.tsx","./src/components/nav/listlink.tsx","./src/components/nav/pagenav.tsx","./src/components/nav/threadlink.tsx","./src/components/post/attachmentlink.tsx","./src/components/post/newscard.tsx","./src/components/post/postactions.tsx","./src/components/post/postbody.tsx","./src/components/post/postmeta.tsx","./src/components/post/replycard.tsx","./src/components/post/threadcard.tsx","./src/components/profile/editprofile.tsx","./src/components/profile/viewprofile.tsx","./src/hooks/usebreadcrumb.tsx","./src/hooks/usediscovery.ts","./src/hooks/usedropdown.ts","./src/hooks/usehandlesearch.ts","./src/hooks/usepagetitle.ts","./src/hooks/useresolvedbbs.ts","./src/hooks/usethreadreplies.ts","./src/lexicons/index.ts","./src/lexicons/types/xyz/atbbs/ban.ts","./src/lexicons/types/xyz/atbbs/board.ts","./src/lexicons/types/xyz/atbbs/hide.ts","./src/lexicons/types/xyz/atbbs/pin.ts","./src/lexicons/types/xyz/atbbs/post.ts","./src/lexicons/types/xyz/atbbs/profile.ts","./src/lexicons/types/xyz/atbbs/site.ts","./src/lib/activity.ts","./src/lib/atproto.ts","./src/lib/atprotoapps.ts","./src/lib/auth.ts","./src/lib/bbs.ts","./src/lib/bsky.ts","./src/lib/cache.ts","./src/lib/deletebbs.ts","./src/lib/lexicon.ts","./src/lib/limits.ts","./src/lib/loginmodal.tsx","./src/lib/mythreads.ts","./src/lib/pins.ts","./src/lib/profile.ts","./src/lib/replies.ts","./src/lib/util.ts","./src/lib/writes.ts","./src/pages/bbs.tsx","./src/pages/board.tsx","./src/pages/dashboard.tsx","./src/pages/home.tsx","./src/pages/loggedouthome.tsx","./src/pages/news.tsx","./src/pages/notfound.tsx","./src/pages/oauthcallback.tsx","./src/pages/profile.tsx","./src/pages/sysopcreate.tsx","./src/pages/sysopedit.tsx","./src/pages/sysopmoderate.tsx","./src/pages/thread.tsx","./src/router/routes.tsx","./src/router/loaders/account.ts","./src/router/loaders/auth.ts","./src/router/loaders/bbs.ts","./src/router/loaders/board.ts","./src/router/loaders/home.ts","./src/router/loaders/index.ts","./src/router/loaders/profile.ts","./src/router/loaders/sysop.ts","./src/router/loaders/thread.ts"],"version":"6.0.2"} 1 + {"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/activitylist.tsx","./src/components/atprotoappscard.tsx","./src/components/bbspanel.tsx","./src/components/dialbbs.tsx","./src/components/discoverylist.tsx","./src/components/errorpage.tsx","./src/components/localtime.tsx","./src/components/loginmodal.tsx","./src/components/mythreadlist.tsx","./src/components/pinbutton.tsx","./src/components/pinnedlist.tsx","./src/components/form/boardroweditor.tsx","./src/components/form/composeform.tsx","./src/components/form/filechips.tsx","./src/components/form/form.tsx","./src/components/form/handleinput.tsx","./src/components/form/handlesuggestions.tsx","./src/components/form/loginform.tsx","./src/components/layout/footer.tsx","./src/components/layout/header.tsx","./src/components/layout/headerbreadcrumbs.tsx","./src/components/layout/layout.tsx","./src/components/layout/logo.tsx","./src/components/layout/mobilebackbutton.tsx","./src/components/layout/mobilemenu.tsx","./src/components/nav/actionbar.tsx","./src/components/nav/actionbutton.tsx","./src/components/nav/listlink.tsx","./src/components/nav/pagenav.tsx","./src/components/nav/threadlink.tsx","./src/components/post/attachmentlink.tsx","./src/components/post/newscard.tsx","./src/components/post/postactions.tsx","./src/components/post/postbody.tsx","./src/components/post/postmeta.tsx","./src/components/post/replycard.tsx","./src/components/post/threadcard.tsx","./src/components/profile/editprofile.tsx","./src/components/profile/viewprofile.tsx","./src/hooks/usebreadcrumb.tsx","./src/hooks/usediscovery.ts","./src/hooks/usedropdown.ts","./src/hooks/usehandlesearch.ts","./src/hooks/usepagetitle.ts","./src/hooks/useresolvedbbs.ts","./src/hooks/usethreadreplies.ts","./src/lexicons/index.ts","./src/lexicons/types/xyz/atbbs/ban.ts","./src/lexicons/types/xyz/atbbs/board.ts","./src/lexicons/types/xyz/atbbs/hide.ts","./src/lexicons/types/xyz/atbbs/pin.ts","./src/lexicons/types/xyz/atbbs/post.ts","./src/lexicons/types/xyz/atbbs/profile.ts","./src/lexicons/types/xyz/atbbs/site.ts","./src/lib/activity.ts","./src/lib/atproto.ts","./src/lib/atprotoapps.ts","./src/lib/auth.ts","./src/lib/bbs.ts","./src/lib/bsky.ts","./src/lib/cache.ts","./src/lib/deletebbs.ts","./src/lib/lexicon.ts","./src/lib/limits.ts","./src/lib/loginmodal.tsx","./src/lib/mythreads.ts","./src/lib/pins.ts","./src/lib/profile.ts","./src/lib/replies.ts","./src/lib/shared.ts","./src/lib/util.ts","./src/lib/writes.ts","./src/pages/bbs.tsx","./src/pages/board.tsx","./src/pages/dashboard.tsx","./src/pages/home.tsx","./src/pages/loggedouthome.tsx","./src/pages/news.tsx","./src/pages/notfound.tsx","./src/pages/oauthcallback.tsx","./src/pages/profile.tsx","./src/pages/sysopcreate.tsx","./src/pages/sysopedit.tsx","./src/pages/sysopmoderate.tsx","./src/pages/thread.tsx","./src/router/routes.tsx","./src/router/loaders/account.ts","./src/router/loaders/auth.ts","./src/router/loaders/bbs.ts","./src/router/loaders/board.ts","./src/router/loaders/home.ts","./src/router/loaders/index.ts","./src/router/loaders/profile.ts","./src/router/loaders/sysop.ts","./src/router/loaders/thread.ts"],"version":"6.0.2"}
+48 -40
web/vite.config.ts
··· 1 1 import { defineConfig } from "vite"; 2 2 import react from "@vitejs/plugin-react"; 3 3 import tailwindcss from "@tailwindcss/vite"; 4 + import shared from "../data/shared.json"; 4 5 5 6 const SERVER_HOST = "127.0.0.1"; 6 7 const SERVER_PORT = 5173; 7 8 8 9 const SCOPE = [ 9 - "atproto", 10 - "blob:*/*", 11 - "repo:xyz.atbbs.site", 12 - "repo:xyz.atbbs.board", 13 - "repo:xyz.atbbs.post", 14 - "repo:xyz.atbbs.ban", 15 - "repo:xyz.atbbs.hide", 16 - "repo:xyz.atbbs.pin", 17 - "repo:xyz.atbbs.profile", 10 + ...shared.oauth_base_scopes, 11 + ...Object.values(shared.lexicon_collections).map((nsid) => `repo:${nsid}`), 18 12 ].join(" "); 13 + 14 + // Placeholder the Docker entrypoint replaces at runtime with PUBLIC_URL. 15 + const PUBLIC_URL_TOKEN = "__PUBLIC_URL__"; 19 16 20 17 interface ClientMetadata { 21 18 client_id: string; ··· 46 43 }; 47 44 } 48 45 46 + function buildConfig(publicUrl: string) { 47 + const u = publicUrl.replace(/\/$/, ""); 48 + return { 49 + client_id: `${u}/client-metadata.json`, 50 + redirect_uri: `${u}/oauth/callback`, 51 + scope: SCOPE, 52 + }; 53 + } 54 + 49 55 /** 50 56 * Dev: synthesizes a loopback client_id (atproto OAuth forbids `localhost`, 51 57 * so the redirect goes to 127.0.0.1). ··· 53 59 * Build with VITE_PUBLIC_URL: emits config.json + client-metadata.json for 54 60 * static deploys (Cloudflare Pages, etc.). 55 61 * 56 - * Build without VITE_PUBLIC_URL: produces a generic bundle. The Docker 57 - * entrypoint generates config.json + client-metadata.json at runtime from 58 - * the PUBLIC_URL env var. 62 + * Build without VITE_PUBLIC_URL: emits *.template.json files with a 63 + * __PUBLIC_URL__ token. The Docker entrypoint substitutes at runtime from 64 + * the PUBLIC_URL env var. NSIDs/scope live in data/shared.json only. 59 65 */ 60 66 export default defineConfig(({ command }) => { 61 67 const isBuild = command === "build"; ··· 71 77 process.env.VITE_OAUTH_SCOPE = SCOPE; 72 78 } 73 79 74 - // Static deploy: emit config.json and client-metadata.json at build time. 75 - let staticFiles: Array<{ fileName: string; source: string }> = []; 76 - if (isBuild && publicUrl) { 77 - if (!publicUrl.startsWith("https://")) { 78 - throw new Error( 79 - `VITE_PUBLIC_URL must use https:// (got ${publicUrl}).`, 80 + const staticFiles: Array<{ fileName: string; source: string }> = []; 81 + if (isBuild) { 82 + if (publicUrl) { 83 + if (!publicUrl.startsWith("https://")) { 84 + throw new Error( 85 + `VITE_PUBLIC_URL must use https:// (got ${publicUrl}).`, 86 + ); 87 + } 88 + staticFiles.push( 89 + { 90 + fileName: "client-metadata.json", 91 + source: JSON.stringify(buildMetadata(publicUrl), null, 2) + "\n", 92 + }, 93 + { 94 + fileName: "config.json", 95 + source: JSON.stringify(buildConfig(publicUrl), null, 2) + "\n", 96 + }, 97 + ); 98 + } else { 99 + staticFiles.push( 100 + { 101 + fileName: "client-metadata.template.json", 102 + source: 103 + JSON.stringify(buildMetadata(PUBLIC_URL_TOKEN), null, 2) + "\n", 104 + }, 105 + { 106 + fileName: "config.template.json", 107 + source: JSON.stringify(buildConfig(PUBLIC_URL_TOKEN), null, 2) + "\n", 108 + }, 80 109 ); 81 110 } 82 - const u = publicUrl.replace(/\/$/, ""); 83 - const metadata = buildMetadata(u); 84 - staticFiles = [ 85 - { 86 - fileName: "client-metadata.json", 87 - source: JSON.stringify(metadata, null, 2) + "\n", 88 - }, 89 - { 90 - fileName: "config.json", 91 - source: 92 - JSON.stringify( 93 - { 94 - client_id: metadata.client_id, 95 - redirect_uri: metadata.redirect_uris[0], 96 - scope: SCOPE, 97 - }, 98 - null, 99 - 2, 100 - ) + "\n", 101 - }, 102 - ]; 103 111 } 104 112 105 113 return { ··· 118 126 server: { 119 127 host: SERVER_HOST, 120 128 port: SERVER_PORT, 121 - // Allow importing ../core/atproto_apps.json (shared with the Python TUI). 129 + // Allow importing ../../data/shared.json (shared with the Python TUI). 122 130 fs: { allow: [".."] }, 123 131 }, 124 132 };