From 0c6f03fca1b1557c7a43d5486ae725e793c6e03d Mon Sep 17 00:00:00 2001 From: Zeng Chuang <215986001+zengchuang-hw@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:34:27 +0800 Subject: [PATCH 1/2] feat: add authorized bulk Star for homepage repositories List the homepage GitHub repositories and add a user-authorized device flow with a server-side token session, fixed repository allowlist, and partial-failure retry. Document GitHub App and same-origin hosting prerequisites. Agent-assisted-by: Codex (implementation) Signed-off-by: Zeng Chuang <215986001+zengchuang-hw@users.noreply.github.com> --- LOCAL_PREVIEW.md | 2 ++ SHA256SUMS | 14 +++++--- STAR_SETUP.md | 50 ++++++++++++++++++++++++++ assistant.js | 2 +- index.html | 2 ++ local-preview.cjs | 5 ++- qa-stars.cjs | 33 +++++++++++++++++ star-repositories.json | 13 +++++++ star-service.cjs | 81 ++++++++++++++++++++++++++++++++++++++++++ stars.css | 16 +++++++++ stars.js | 30 ++++++++++++++++ 11 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 STAR_SETUP.md create mode 100644 qa-stars.cjs create mode 100644 star-repositories.json create mode 100644 star-service.cjs create mode 100644 stars.css create mode 100644 stars.js diff --git a/LOCAL_PREVIEW.md b/LOCAL_PREVIEW.md index 1dc6567..2580643 100644 --- a/LOCAL_PREVIEW.md +++ b/LOCAL_PREVIEW.md @@ -10,6 +10,8 @@ node local-preview.cjs 当前助手为本地概念与交互演示:使用预设资料导航和示例回复,没有连接真实模型,也不会发送模型 API 请求。 +项目区新增“一键 Star 全部仓库”。实际使用需配置项目自有的 GitHub App 和授权服务,见 [STAR_SETUP.md](STAR_SETUP.md)。未配置时会显示仓库清单和明确提示,不会假报 Star 成功。 + 建议测试:导航栏右上角的小矩头像;导航栏滚出视野后出现的右下角透明全身贴图(桌面高 120px,手机高 100px);回到顶部时贴图收起;面板展开与收起、快捷问题、输入发送、项目上下文、手机窄屏布局及键盘操作。首页正文没有角色立绘或欢迎卡片。资料链接可跳转至外部项目页面。 若在终端前台启动,测试结束后可在该终端按 `Ctrl+C` 结束本次服务。 diff --git a/SHA256SUMS b/SHA256SUMS index c42efb6..4e39f66 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -4,9 +4,10 @@ cd03e6eab67eed80970fc2e7fb8a0d6e3f65c9bda7e8ea27e02a87adde06ecd2 .gitignore dc2b68dea37313b64e7a5e585f68be9164e3ebfae84ed5614a28519159a68454 ATTRIBUTION.md a7b206789701abc1335853910298dcd9b397e9521704759c225dcd0d5fd2f61c BENCHMARK_EVIDENCE.json 304b20c046a82dd9cd6269151ecbe09f7807fd81376c227121f77517321cb126 INFERRSI.md -9b37f8f61e35ca9998499716bd299a03f05d81208acae3bb35900807a0fc55ad LOCAL_PREVIEW.md +63a0150c8d85d98620be1fbd2693c446680f7911ccb77fc347870f48b79eb0e9 LOCAL_PREVIEW.md 51ce2153087f31b813b2ba275c3d9456af4e5a951f46ebaf97d59ee9ce10d8aa README.md fafa3b155e999c060cdd9b884484c968348929397362a03483dabfd84ef2a62e SHOWCASE.md +73813d0d5fa345698a7b7642f04847888a0153316e9ef532f0b9cfe8f8ba16b3 STAR_SETUP.md 0bc3d5f7bd34ac53892f39ab97de48a9d771ddbfc35676255fa46292e4dcae81 app.js 53cf5937ee480102fbabd8e26a52adf1e936a49c2c24d0fc1948465982466017 assets/architecture/afd.svg c2d920840043570ad8b25f1f43f87a2afe72c7e7bbac5a7309b599929032bd45 assets/architecture/afd.svg.license @@ -39,17 +40,22 @@ ca3598fb776abba7c70b96199440da23ef9ba6af9456f594c810e00b9b1761ec assets/showcas 8e7e0bdd2bbe622dd85b5c06ba508293ab9bb3ae8f4f530c4104ed89af5bd536 assets/showcase/workswarm.jpg f1e5d4f2cc41c9c1da69dfb3523c5659012ac8169d06c267b165af5ecd2aa698 assets/showcase/workswarm.mp4 30e9b46a6d06ab864bbc7ca0289143822d1192392074dc965b0683559f6911e0 assistant.css -71ba38902b5a33b28d7bb66f5a90d83a73cbc4ca9b0d037289b7abce221a30c2 assistant.js +28b135f8c6626c4ae8bd98140a2c32794a1bf718a899ea6030f47f4d7830a1d7 assistant.js 1c13002720c8d65a3fc98084c708b9058827ab66740ae52105991cfebc60eb8e dom-fixture.json -e8a9bbc48eca6225629cc2cbb31629886dfd72228fac74875856bd31b597e245 index.html +55f84f9a0a4f1f63709cd9fb43693d5552f86b658305410375061444ba456b7d index.html acfa2bbd15ebbe680b46d0a542b4af11b893f425340d8ffe13e35f1f66523706 inferrsi.js ae0fe01b8c81c20e4a607f8608b84ac0df3b42f9e38bcaaefde92cda5c63a782 initial-data.json 0e8566ea362276878e1f38f97da49964dbea301c15d4aa9d727f97a4c39fd943 licenses/afd-plugin-LICENSE.txt c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 licenses/vllm-omni-LICENSE.txt -19cc4e8b177647501dc4c8fa02ce38e90f0c1f3efe70fe2aa3409930bde7aae1 local-preview.cjs +0c7bb254b590efd08f16e91d5677a79d96ee5acad1dde7ee1a0bc141f69950aa local-preview.cjs 250f9ee919523549bbabe67fa4585a2772a405f8364b2a370b4020e61aedb8b6 prepare-initial-content.cjs 33b901a79c77f170fd4f11d7a67113f31f96bf91c58006d8d3d5a7530bb90d58 qa-interactions.cjs +0d3d6a2ba3b21100c5d86a8a325244ae205eb78f69551709bedd41db5cbf6cc4 qa-stars.cjs 51a3881b90c67476605580f3a859becf174b8bf67d37c5c1200324613f2ab42d robots.txt 62f5e8d2444784d0e8df287e3b28931d1a259bf61d71c0636abb8a16add3ba31 showcase.js 741e991c7d0bc12c49acc061ba697dcc5e57946380c708cc823f7fadcb3d6485 sitemap.xml +a6c7baab91939560795b458fe0246e7a111f128badc135e1b766f5a46f806ff2 star-repositories.json +e1c71a66f7059821849ff403f9834d17eecef086462d6b08ed285164fc15009f star-service.cjs +c892cd8794c3cba5a4b6b3bee0b2c57921a0fa6ab8df1269acc6b4046012df39 stars.css +4445f6e99f1af780aaf2ca3ecb556a07346065dc267d75d88c5d3f00f4010589 stars.js 8ad287859fb0665c8114c44cce235ec47974c9dae9204bf9775540f3c430c712 styles.css diff --git a/STAR_SETUP.md b/STAR_SETUP.md new file mode 100644 index 0000000..0b4dd5e --- /dev/null +++ b/STAR_SETUP.md @@ -0,0 +1,50 @@ +# Bulk Star service + +The homepage lists all 11 GitHub repositories linked in its HTML, including the +seven ecosystem projects, three engine examples and the marketing asset library. +`star-repositories.json` is the server allowlist; update it alongside homepage +links. `node --test qa-stars.cjs` checks coverage and the authorization boundary. + +## Configure a project-owned GitHub App + +1. Register a public GitHub App with the project's homepage URL. Enable **Device + Flow**. Disable webhooks if unused. No callback or client secret is needed for + this flow. +2. Grant **Account permissions → Starring: read and write** and the required + **Repository permissions → Metadata: read**. Do not grant code-write access. +3. Inject the public Client ID as `GITHUB_STAR_CLIENT_ID` in the server process. + Do not paste user access tokens or secrets into the page or tracked files. +4. Run `node local-preview.cjs` (Node 22+). Open . + The first click lists repositories; “Authorize & star all” requests a code. + The visitor opens GitHub, enters that code, and grants access. The page then + automatically sends the batch and shows individual results. Retry skips + successful items; existing stars are preserved by GitHub's idempotent PUT. + +## Hosting + +GitHub Pages serves static files and cannot run the authorization API. Keep the +static site on Pages and route `/api/stars/*` through a same-origin HTTPS reverse +proxy to a Node service invoking `createStarService` from `star-service.cjs`. +Set `STAR_PUBLIC_ORIGIN` to the exact public HTTPS origin. The included preview +server binds loopback only; a production process can remain behind the local +reverse proxy. Forward API responses unchanged, including `Set-Cookie`, and do +not cache them. Do not enable cross-origin API access or log cookies, tokens, +upstream payloads, or authorization codes. Apply edge request rate limits before +public exposure, especially to `/start`. Host the single-process API separately +from static file delivery; in-memory sessions are not shared between replicas. + +Tokens stay only in server memory, bound to a random HttpOnly/SameSite cookie. +Sessions expire within 15 minutes; browser close of the dialog discards the +session, stopping remaining requests after any in-flight request. Successful +stars remain. Server restart requires fresh authorization. No refresh token is +retained. Users can revoke app authorization in GitHub settings. Errors and +limits are reported without exposing GitHub payloads. No real stars are made by +the test suite: it injects a simulated GitHub transport. Production authorization +has not been verified until a project-owned app and hosting are configured. + +If the service is unavailable or unconfigured, the page shows a clear notice and +individual repository links. It never substitutes opening tabs for a successful +batch operation. + +References: [GitHub App device flow](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-device-flow-to-generate-a-user-access-token), +[Starring API and permissions](https://docs.github.com/en/rest/activity/starring#star-a-repository-for-the-authenticated-user). diff --git a/assistant.js b/assistant.js index 8b7e7f1..d8371e3 100644 --- a/assistant.js +++ b/assistant.js @@ -180,7 +180,7 @@ function syncMotion(){document.body.classList.toggle('mira-paused',motion.matches || $('#motion-toggle')?.getAttribute('aria-pressed')==='true');} new MutationObserver(syncMotion).observe($('#motion-toggle'),{attributes:true,attributeFilter:['aria-pressed']});motion.addEventListener('change',syncMotion);mobile.addEventListener('change',syncMobile); function syncDialogs(){const blocked=!!document.querySelector('dialog[open]') || !!document.fullscreenElement;document.body.classList.toggle('mira-modal-blocked',blocked);if(blocked && isOpen)closePanel(false);} - document.querySelectorAll('dialog').forEach(d=>new MutationObserver(syncDialogs).observe(d,{attributes:true,attributeFilter:['open']}));document.addEventListener('fullscreenchange',syncDialogs); + new MutationObserver(syncDialogs).observe(document.body,{subtree:true,attributes:true,attributeFilter:['open']});document.addEventListener('fullscreenchange',syncDialogs); if(hidden)document.body.classList.add('mira-hidden'); translate();syncMotion();syncDialogs();syncLaunchers();dataReady.then(()=>{if(conversation.length)renderThread();}); })(); diff --git a/index.html b/index.html index 138cd15..59ad651 100644 --- a/index.html +++ b/index.html @@ -16,10 +16,12 @@ + + diff --git a/local-preview.cjs b/local-preview.cjs index b68cac5..eb08840 100644 --- a/local-preview.cjs +++ b/local-preview.cjs @@ -1,10 +1,13 @@ -// Local-only static preview. No dependencies, AI requests or credentials. +// Loopback preview with optional GitHub Star service; no model requests. const http = require('node:http'); const fs = require('node:fs'); const path = require('node:path'); const root = __dirname; +const {createStarService}=require('./star-service.cjs'); +const starService=createStarService({clientId:process.env.GITHUB_STAR_CLIENT_ID,origin:process.env.STAR_PUBLIC_ORIGIN || `http://127.0.0.1:${process.env.MIRA_PREVIEW_PORT || 4173}`}); const types = {'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8','.json':'application/json; charset=utf-8','.svg':'image/svg+xml','.png':'image/png','.webp':'image/webp','.jpg':'image/jpeg','.jpeg':'image/jpeg','.mp4':'video/mp4','.txt':'text/plain; charset=utf-8'}; const server = http.createServer((req,res)=>{ + if(req.url.startsWith('/api/stars/')){starService(req,res).catch(()=>{if(!res.headersSent)res.writeHead(500);res.end();});return;} if(!['GET','HEAD'].includes(req.method)){res.writeHead(405);res.end();return;} let pathname;try{pathname=decodeURIComponent(new URL(req.url,'http://localhost').pathname);}catch{res.writeHead(400);res.end();return;} if(pathname.includes('\0')){res.writeHead(400);res.end();return;} diff --git a/qa-stars.cjs b/qa-stars.cjs new file mode 100644 index 0000000..48a2977 --- /dev/null +++ b/qa-stars.cjs @@ -0,0 +1,33 @@ +'use strict'; +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const http=require('node:http'); +const fs=require('node:fs'); +const {createStarService}=require('./star-service.cjs'); +const repos=require('./star-repositories.json'); +const origin='https://example.com'; +async function fixture(t,{configured=true,status=204,pollError=null}={}){ + let time=100000,puts=[],tokenCalls=0; + const fetcher=async(url,options)=>{ + if(url.endsWith('/device/code'))return Response.json({device_code:'synthetic-device',user_code:'TEST-CODE',interval:5,expires_in:900}); + if(url.endsWith('/access_token')){tokenCalls++;return Response.json(pollError?{error:pollError}:{access_token:'synthetic-token'});} + assert.match(url,/^https:\/\/api.github.com\/user\/starred\//);assert.equal(options.method,'PUT');assert.equal(options.headers.Authorization,'Bearer synthetic-token'); + puts.push(url.split('/starred/')[1]);return new Response(null,{status:typeof status==='function'?status(puts.length):status}); + }; + const handler=createStarService({clientId:configured?'synthetic-client':'',origin,fetcher,now:()=>time,pause:async()=>{}}); + const server=http.createServer((req,res)=>handler(req,res));await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r))); + let cookie=''; + const request=async(path,options={})=>{const r=await fetch(`http://127.0.0.1:${server.address().port}/api/stars/${path}`,{method:'POST',headers:{Origin:origin,'X-Requested-With':'InferMatrixStars',Cookie:cookie,...options.headers},...options});const c=r.headers.get('set-cookie');if(c)cookie=c.split(';')[0];return {status:r.status,data:await r.json(),cookie:c};}; + return {request,advance:ms=>time+=ms,puts,tokenCalls:()=>tokenCalls,login:async()=>{await request('start');time+=5000;return request('poll');}}; +} +test('catalog matches every homepage GitHub repository, without duplicate case variants',()=>{ + const linked=[...fs.readFileSync('index.html','utf8').matchAll(/https:\/\/github.com\/([\w.-]+\/[\w.-]+)/g)].map(m=>m[1].toLowerCase()); + assert.deepEqual([...new Set(linked)].sort(),repos.map(r=>r.toLowerCase()).sort());assert.equal(new Set(repos.map(r=>r.toLowerCase())).size,repos.length); +}); +test('unconfigured service is explicit and does not authorize',async t=>{const f=await fixture(t,{configured:false});assert.equal((await f.request('status',{method:'GET'})).data.configured,false);assert.equal((await f.request('start')).status,503);}); +test('reject cross-origin and missing custom header; batch requires login',async t=>{const f=await fixture(t);assert.equal((await f.request('start',{headers:{Origin:'https://other.example'}})).status,403);assert.equal((await f.request('start',{headers:{Origin:origin}})).status,403);assert.equal((await f.request('all')).status,401);assert.equal(f.puts.length,0);}); +test('device polling is throttled and tokens never leave the server; retries are idempotent',async t=>{const f=await fixture(t);const start=await f.request('start');assert.match(start.cookie,/HttpOnly; SameSite=Strict; Path=\/; Max-Age=900; Secure/);assert.equal(start.data.device_code,undefined);assert.equal((await f.request('poll')).data.pending,true);assert.equal(f.tokenCalls(),0);f.advance(5000);assert.deepEqual((await f.request('poll')).data,{authorized:true});const batch=await f.request('all');assert.ok(batch.data.results.every(r=>r.ok));assert.deepEqual(f.puts,repos);await f.request('all');assert.equal(f.puts.length,repos.length);assert.ok(!JSON.stringify(batch).includes('synthetic-token'));}); +test('rate limit stops the batch and never reports unattempted stars as success',async t=>{const f=await fixture(t,{status:403});await f.login();const batch=await f.request('all');assert.equal(f.puts.length,1);assert.equal(batch.data.results.length,repos.length);assert.ok(batch.data.results.every(r=>!r.ok));}); +test('partial success retries only failed repositories',async t=>{const f=await fixture(t,{status:n=>n===2?500:204});await f.login();assert.equal((await f.request('all')).data.results.filter(r=>r.ok).length,repos.length-1);await f.request('all');assert.equal(f.puts.length,repos.length+1);assert.equal(f.puts.at(-1),repos[1]);}); +test('slow_down extends the poll interval; denial and expiry clear sessions',async t=>{const f=await fixture(t,{pollError:'slow_down'});await f.request('start');f.advance(5000);assert.equal((await f.request('poll')).data.interval,10);f.advance(5000);await f.request('poll');assert.equal(f.tokenCalls(),1);f.advance(900000);assert.equal((await f.request('poll')).status,401);const g=await fixture(t,{pollError:'access_denied'});await g.request('start');g.advance(5000);assert.equal((await g.request('poll')).status,401);assert.equal((await g.request('all')).status,401);}); +test('logout discards authorization',async t=>{const f=await fixture(t);await f.login();await f.request('logout');assert.equal((await f.request('all')).status,401);assert.equal(f.puts.length,0);}); diff --git a/star-repositories.json b/star-repositories.json new file mode 100644 index 0000000..13aa89b --- /dev/null +++ b/star-repositories.json @@ -0,0 +1,13 @@ +[ + "vllm-project/vllm-omni", + "vllm-project/afd-plugin", + "vllm-project/router", + "openJiuwen-ai/agent-infer", + "ThinkFlowLab/vllm-rlt", + "ThinkFlowLab/vllm-omni-mlx", + "InferMatrixLab/InferRSI", + "InferMatrixLab/qwen3-tts-engine", + "InferMatrixLab/drama-3-single-b300-engine", + "InferMatrixLab/minicpm-o-4.5-single-gpu-engine", + "InferMatrixLab/infermatrix-marketing" +] diff --git a/star-service.cjs b/star-service.cjs new file mode 100644 index 0000000..8a86db8 --- /dev/null +++ b/star-service.cjs @@ -0,0 +1,81 @@ +'use strict'; +const {randomBytes}=require('node:crypto'); +const repositories=require('./star-repositories.json'); + +// Single-process, short-lived sessions. Never serialize tokens or log upstream responses. +function createStarService({clientId='',origin='http://127.0.0.1:4173',fetcher=fetch,now=Date.now,pause=ms=>new Promise(r=>setTimeout(r,ms))}={}) { + const sessions=new Map(); + const forget=key=>{clearTimeout(sessions.get(key)?.timer);sessions.delete(key);}; + const cookieName=new URL(origin).protocol==='https:'?'__Host-infermatrix-star':'infermatrix-star'; + async function upstream(url,options){return fetcher(url,{...options,redirect:'error',signal:AbortSignal.timeout(15000)});} + async function oauth(path,params){const r=await upstream('https://github.com/login/'+path,{method:'POST',headers:{Accept:'application/json','Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams(params)});if(!r.ok)throw Error('upstream');return r.json();} + return async function handle(req,res){ + const path=new URL(req.url,origin).pathname; + const send=(status,data)=>{res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store','X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer'});res.end(JSON.stringify(data));}; + if(path==='/api/stars/status' && req.method==='GET'){send(200,{configured:!!clientId,repositories});return;} + if(req.method!=='POST'){send(405,{error:'method'});return;} + if(req.headers.origin!==origin || req.headers['x-requested-with']!=='InferMatrixStars'){send(403,{error:'origin'});return;} + for(const [key,s]of sessions)if(s.expires<=now()&&!s.busy)forget(key); + const id=(req.headers.cookie||'').split(';').map(c=>c.trim()).find(c=>c.startsWith(cookieName+'='))?.slice(cookieName.length+1); + let session=sessions.get(id); + const cookie=(value,age)=>res.setHeader('Set-Cookie',`${cookieName}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${age}${origin.startsWith('https:')?'; Secure':''}`); + if(path==='/api/stars/logout'){forget(id);cookie('',0);send(200,{ok:true});return;} + if(!clientId){send(503,{error:'not_configured'});return;} + try { + if(path==='/api/stars/start'){ + if(session){send(409,{error:'session_exists'});return;} + if(sessions.size>=100){send(429,{error:'busy'});return;} + // Reserve capacity before awaiting GitHub, limiting concurrent anonymous starts. + const key=randomBytes(32).toString('hex'); + session={expires:now()+15*60*1000,busy:true,done:new Set()};sessions.set(key,session); + session.timer=setTimeout(()=>forget(key),15*60*1000);session.timer.unref?.(); + let d; + try{d=await oauth('device/code',{client_id:clientId});}catch{forget(key);throw Error('upstream');} + if(d.error || !d.device_code || !d.user_code){forget(key);send(502,{error:'authorization_unavailable'});return;} + session.device=d.device_code;session.interval=Math.max(5,Number(d.interval)||5);session.next=now()+session.interval*1000; + session.expires=now()+Math.min(900,Number(d.expires_in)||900)*1000;session.busy=false; + cookie(key,900);send(200,{userCode:d.user_code,verificationUri:'https://github.com/login/device',interval:session.interval});return; + } + if(!session || session.expires<=now()){send(401,{error:'expired'});return;} + if(session.busy){send(409,{error:'busy'});return;} + if(path==='/api/stars/poll'){ + if(session.token){send(200,{authorized:true});return;} + if(now()r.repository===repository))results.push({repository,ok:session.done.has(repository),status:0}); + send(200,{results});return; + }finally{session.busy=false;} + } + send(404,{error:'not_found'}); + }catch{send(502,{error:'github_unavailable'});} + }; +} +module.exports={createStarService}; diff --git a/stars.css b/stars.css new file mode 100644 index 0000000..323b73d --- /dev/null +++ b/stars.css @@ -0,0 +1,16 @@ +.star-all-entry { margin: 20px 0; cursor: pointer; } +.star-all-dialog { width: min(600px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 30px; border: 1px solid #45627c; border-radius: 20px; background: #0a172b; color: #edf6ff; overflow: auto; } +.star-all-dialog::backdrop { background: #020812cc; backdrop-filter: blur(4px); } +.star-all-dialog h2 { font-size: 23px; padding-right: 26px; } +.star-all-close { float: right; width: 40px; height: 40px; border: 0; border-radius: 8px; background: #20334a; color: inherit; cursor: pointer; font-size: 25px; } +.star-all-description,.star-all-status,.star-all-auth { font-size: 14px; line-height: 1.8; color: #b8cce0; } +.star-all-list { padding: 0; list-style: none; margin: 20px 0; } +.star-all-list li { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #23384d; font: 12px/1.7 var(--mono); overflow-wrap: anywhere; } +.star-all-list a { color: #9fdae4; } +.star-all-list span { font-family: var(--font); } +.star-ok { color: #8be5be; }.star-error { color: #f1c58f; } +.star-all-auth { padding: 15px; border: 1px solid #45627c; border-radius: 10px; margin-bottom: 18px; } +.star-all-code { display: block; font: 26px/2 var(--mono); letter-spacing: 3px; color: #fff; user-select: all; } +.star-all-submit { cursor: pointer; white-space: normal; }.star-all-submit:disabled { opacity: .55; cursor: default; } +.star-all-dialog :focus-visible { outline: 2px solid #8de5ed; outline-offset: 3px; } +@media(max-width: 480px) { .star-all-dialog { padding: 20px; } } diff --git a/stars.js b/stars.js new file mode 100644 index 0000000..74233d1 --- /dev/null +++ b/stars.js @@ -0,0 +1,30 @@ +'use strict'; +(() => { + const zh=()=>document.documentElement.lang.startsWith('zh'); + const copy=(a,b)=>zh()?a:b; + const entry=document.createElement('button');entry.className='star-all-entry button button-secondary';entry.type='button'; + document.querySelector('#projects .section-heading')?.append(entry); + if(!entry.isConnected)document.querySelector('#projects .project-filters, #projects .filters')?.before(entry); + if(!entry.isConnected)document.querySelector('#projects').prepend(entry); + const dialog=document.createElement('dialog');dialog.className='star-all-dialog';dialog.setAttribute('aria-labelledby','star-all-title'); + dialog.innerHTML='

'; + document.body.append(dialog); + const $=s=>dialog.querySelector(s),button=$('.star-all-submit'),status=$('.star-all-status'); + let configured=false,busy=false,authorized=false,timer=null,generation=0,catalog=[],results=null; + const request=async path=>{const r=await fetch('/api/stars/'+path,{method:'POST',credentials:'same-origin',headers:{'X-Requested-With':'InferMatrixStars'}});const d=await r.json();if(!r.ok)throw Error(d.error||'unavailable');return d;}; + let authorizationRequests=Promise.resolve(); + // Order cookie-changing requests so a late start cannot replace a newer session. + const api=path=>{if(path==='all')return request(path);const operation=authorizationRequests.then(()=>request(path));authorizationRequests=operation.catch(()=>{});return operation;}; + function translate(){entry.textContent=copy('☆ 一键 Star 全部仓库','☆ Star all repositories');$('.star-all-close').setAttribute('aria-label',copy('关闭 Star 窗口','Close Star dialog'));$('#star-all-title').textContent=copy('支持 InferMatrix 开源生态','Support the InferMatrix ecosystem');$('.star-all-description').textContent=copy(`为主页展示的 ${catalog.length} 个 GitHub 仓库添加 Star。首次需在 GitHub 登录并授权,授权后自动完成;已经 Star 的仓库保持不变。`,`Star all ${catalog.length} GitHub repositories linked on this homepage. First sign in and authorize on GitHub; the batch then runs automatically. Existing stars stay starred.`);button.textContent=busy?copy('正在处理…','Working…'):authorized?copy('重试未成功的仓库','Retry unsuccessful repositories'):copy('授权并一键 Star','Authorize & star all');$('.star-all-auth p').textContent=copy('在 GitHub 输入此验证码,确认仅授予 Star 权限:','Enter this code on GitHub and review the Starring permission:');$('.star-all-auth a').textContent=copy('前往 GitHub 授权 ↗','Authorize on GitHub ↗');renderList();} + function renderList(){const list=$('.star-all-list');list.replaceChildren();for(const repository of catalog){const li=document.createElement('li'),a=document.createElement('a');a.href='https://github.com/'+repository;a.target='_blank';a.rel='noopener noreferrer';a.textContent=repository;li.append(a);const r=results?.find(x=>x.repository===repository);if(r){const label=document.createElement('span');label.textContent=r.ok?copy('已 Star ✓','Starred ✓'):copy('未成功 · 可重试','Unsuccessful · retry');label.className=r.ok?'star-ok':'star-error';li.append(label);}list.append(li);}} + function reset(){clearTimeout(timer);generation++;busy=false;authorized=false;results=null;$('.star-all-auth').hidden=true;button.disabled=!configured;translate();} + async function load(){reset();status.textContent=copy('正在获取仓库清单…','Loading repositories…');try{const r=await fetch('/api/stars/status',{cache:'no-store'});if(!r.ok)throw Error();const d=await r.json();configured=d.configured;catalog=d.repositories;}catch{configured=false;try{catalog=await (await fetch('star-repositories.json')).json();}catch{catalog=[];}}button.disabled=!configured;status.textContent=configured?copy('仅操作以下仓库,GitHub 访问令牌仅保存在服务端。','Only the repositories below will be starred. GitHub access tokens stay on the server.'):copy('批量 Star 服务尚未配置。你可以先通过清单中的链接访问各仓库。','Bulk Star is not configured yet. You can visit each repository below.');translate();} + async function star(run){status.textContent=copy('正在逐个添加 Star,请稍候…','Starring repositories, please wait…');const d=await api('all');if(run!==generation)return;results=d.results;const count=results.filter(r=>r.ok).length;status.textContent=copy(`已 Star ${count} / ${catalog.length} 个仓库。`,`Starred ${count} / ${catalog.length} repositories.`);busy=false;button.disabled=count===catalog.length;translate();} + async function poll(run,interval){timer=setTimeout(async()=>{if(run!==generation)return;try{const d=await api('poll');if(run!==generation)return;if(d.authorized){authorized=true;$('.star-all-auth').hidden=true;await star(run);}else poll(run,d.interval||interval);}catch{if(run===generation)fail();}},interval*1000);} + function fail(){clearTimeout(timer);busy=false;status.textContent=copy('操作未完成,可能是授权过期、权限不足或 GitHub 暂时不可用。请关闭窗口后重试;已添加的 Star 会保留。','Not completed: authorization may have expired, permissions may be missing, or GitHub may be unavailable. Close and reopen to retry; completed stars are retained.');button.disabled=true;translate();} + entry.addEventListener('click',()=>{dialog.showModal();load();}); + button.addEventListener('click',async()=>{if(busy)return;busy=true;button.disabled=true;translate();const run=generation;try{if(authorized){await star(run);return;}await api('logout');if(run!==generation)return;const d=await api('start');if(run!==generation)return;$('.star-all-code').textContent=d.userCode;$('.star-all-auth').hidden=false;status.textContent=copy('等待 GitHub 授权;完成后会自动 Star 上方全部仓库。','Waiting for GitHub authorization; all repositories above will then be starred automatically.');poll(run,d.interval);}catch{if(run===generation)fail();}}); + $('.star-all-close').addEventListener('click',()=>dialog.close()); + dialog.addEventListener('close',()=>{reset();api('logout').catch(()=>{});entry.focus();}); + new MutationObserver(translate).observe(document.documentElement,{attributes:true,attributeFilter:['lang']});translate(); +})(); From 3dcb15a58f99ccdaf3a29d77e216bfac4f59d243 Mon Sep 17 00:00:00 2001 From: Zeng Chuang <215986001+zengchuang-hw@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:40:58 +0800 Subject: [PATCH 2/2] feat: simplify Star authorization and add local simulation Use GitHub web authorization with state and S256 PKCE so visitors return directly to automatic starring. Add an explicit loopback-only simulation for testing without a GitHub App, and prevent repeated authorization while restoring the session. Agent-assisted-by: Codex (implementation) Signed-off-by: Zeng Chuang <215986001+zengchuang-hw@users.noreply.github.com> --- LOCAL_PREVIEW.md | 2 + SHA256SUMS | 14 ++--- STAR_SETUP.md | 41 +++++++++---- local-preview.cjs | 5 +- qa-stars.cjs | 47 +++++++-------- star-service.cjs | 144 ++++++++++++++++++++++++---------------------- stars.css | 5 +- stars.js | 54 +++++++++++------ 8 files changed, 177 insertions(+), 135 deletions(-) diff --git a/LOCAL_PREVIEW.md b/LOCAL_PREVIEW.md index 2580643..874f112 100644 --- a/LOCAL_PREVIEW.md +++ b/LOCAL_PREVIEW.md @@ -12,6 +12,8 @@ node local-preview.cjs 项目区新增“一键 Star 全部仓库”。实际使用需配置项目自有的 GitHub App 和授权服务,见 [STAR_SETUP.md](STAR_SETUP.md)。未配置时会显示仓库清单和明确提示,不会假报 Star 成功。 +无需 GitHub App 也能体验完整操作:运行 `node local-preview.cjs --star-demo`,点击“一键 Star 全部仓库”→“体验模拟授权”→“模拟允许并返回主页”。返回后自动展示 11 个仓库的模拟结果。页面始终有模拟标识,不会请求 GitHub 或修改真实 Star。正式授权采用 GitHub 登录后自动返回,无需用户输入验证码或复制令牌。 + 建议测试:导航栏右上角的小矩头像;导航栏滚出视野后出现的右下角透明全身贴图(桌面高 120px,手机高 100px);回到顶部时贴图收起;面板展开与收起、快捷问题、输入发送、项目上下文、手机窄屏布局及键盘操作。首页正文没有角色立绘或欢迎卡片。资料链接可跳转至外部项目页面。 若在终端前台启动,测试结束后可在该终端按 `Ctrl+C` 结束本次服务。 diff --git a/SHA256SUMS b/SHA256SUMS index 4e39f66..3db08f2 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -4,10 +4,10 @@ cd03e6eab67eed80970fc2e7fb8a0d6e3f65c9bda7e8ea27e02a87adde06ecd2 .gitignore dc2b68dea37313b64e7a5e585f68be9164e3ebfae84ed5614a28519159a68454 ATTRIBUTION.md a7b206789701abc1335853910298dcd9b397e9521704759c225dcd0d5fd2f61c BENCHMARK_EVIDENCE.json 304b20c046a82dd9cd6269151ecbe09f7807fd81376c227121f77517321cb126 INFERRSI.md -63a0150c8d85d98620be1fbd2693c446680f7911ccb77fc347870f48b79eb0e9 LOCAL_PREVIEW.md +49ba892018cdb9707293ffa38351b63834234ad258430efbbd792b42043c124e LOCAL_PREVIEW.md 51ce2153087f31b813b2ba275c3d9456af4e5a951f46ebaf97d59ee9ce10d8aa README.md fafa3b155e999c060cdd9b884484c968348929397362a03483dabfd84ef2a62e SHOWCASE.md -73813d0d5fa345698a7b7642f04847888a0153316e9ef532f0b9cfe8f8ba16b3 STAR_SETUP.md +5b52331f0c9bfa13ec0174f62be1efd764fe03ed19ca659c5868bf42245dc387 STAR_SETUP.md 0bc3d5f7bd34ac53892f39ab97de48a9d771ddbfc35676255fa46292e4dcae81 app.js 53cf5937ee480102fbabd8e26a52adf1e936a49c2c24d0fc1948465982466017 assets/architecture/afd.svg c2d920840043570ad8b25f1f43f87a2afe72c7e7bbac5a7309b599929032bd45 assets/architecture/afd.svg.license @@ -47,15 +47,15 @@ acfa2bbd15ebbe680b46d0a542b4af11b893f425340d8ffe13e35f1f66523706 inferrsi.js ae0fe01b8c81c20e4a607f8608b84ac0df3b42f9e38bcaaefde92cda5c63a782 initial-data.json 0e8566ea362276878e1f38f97da49964dbea301c15d4aa9d727f97a4c39fd943 licenses/afd-plugin-LICENSE.txt c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 licenses/vllm-omni-LICENSE.txt -0c7bb254b590efd08f16e91d5677a79d96ee5acad1dde7ee1a0bc141f69950aa local-preview.cjs +9cf05d3e1319bd85148c179fb934925883df8dff4f836dbfc53d2626ccfcedb4 local-preview.cjs 250f9ee919523549bbabe67fa4585a2772a405f8364b2a370b4020e61aedb8b6 prepare-initial-content.cjs 33b901a79c77f170fd4f11d7a67113f31f96bf91c58006d8d3d5a7530bb90d58 qa-interactions.cjs -0d3d6a2ba3b21100c5d86a8a325244ae205eb78f69551709bedd41db5cbf6cc4 qa-stars.cjs +536fe19b242a6c8e5bebf4229c7081b4ab82bb08627b7c6c3c1d32495ffd590c qa-stars.cjs 51a3881b90c67476605580f3a859becf174b8bf67d37c5c1200324613f2ab42d robots.txt 62f5e8d2444784d0e8df287e3b28931d1a259bf61d71c0636abb8a16add3ba31 showcase.js 741e991c7d0bc12c49acc061ba697dcc5e57946380c708cc823f7fadcb3d6485 sitemap.xml a6c7baab91939560795b458fe0246e7a111f128badc135e1b766f5a46f806ff2 star-repositories.json -e1c71a66f7059821849ff403f9834d17eecef086462d6b08ed285164fc15009f star-service.cjs -c892cd8794c3cba5a4b6b3bee0b2c57921a0fa6ab8df1269acc6b4046012df39 stars.css -4445f6e99f1af780aaf2ca3ecb556a07346065dc267d75d88c5d3f00f4010589 stars.js +20c327926e78e187d970c6e83a6ea1e4131cbea0fdf703781aa3f88ce37e1116 star-service.cjs +90f0dbbeb5f1e180c83769982124c455fe21fe1489a01aaa0e93845a5932b51d stars.css +8b53a00d84c1e1096e7f0fc91da3f2af203e4709fa0bae9d5ee062174eb97125 stars.js 8ad287859fb0665c8114c44cce235ec47974c9dae9204bf9775540f3c430c712 styles.css diff --git a/STAR_SETUP.md b/STAR_SETUP.md index 0b4dd5e..65d868b 100644 --- a/STAR_SETUP.md +++ b/STAR_SETUP.md @@ -5,20 +5,34 @@ seven ecosystem projects, three engine examples and the marketing asset library. `star-repositories.json` is the server allowlist; update it alongside homepage links. `node --test qa-stars.cjs` checks coverage and the authorization boundary. -## Configure a project-owned GitHub App +## Local test without a GitHub App -1. Register a public GitHub App with the project's homepage URL. Enable **Device - Flow**. Disable webhooks if unused. No callback or client secret is needed for - this flow. +Run `node local-preview.cjs --star-demo`, then open +. Click the bulk-Star entry, choose simulated +authorization, and approve on the clearly labelled local confirmation page. +It returns to the homepage and shows simulated per-repository results. There +are no verification codes, credentials or GitHub requests in this mode. The +banner and results explicitly say simulation. Demo mode refuses public origins +and is disabled by default. Do not expose the demo server publicly. + +## Configure a project-owned GitHub App (once, by the operator) + +1. Register a public GitHub App with the project's homepage URL. Add the exact + callback URL `/api/stars/callback`. For local real integration, + register `http://127.0.0.1:4173/api/stars/callback` as well. Disable webhooks if + unused. Device Flow is not needed. 2. Grant **Account permissions → Starring: read and write** and the required **Repository permissions → Metadata: read**. Do not grant code-write access. -3. Inject the public Client ID as `GITHUB_STAR_CLIENT_ID` in the server process. - Do not paste user access tokens or secrets into the page or tracked files. +3. Inject the Client ID as `GITHUB_STAR_CLIENT_ID` and the Client Secret as + `GITHUB_STAR_CLIENT_SECRET` into the server process at runtime. Keep the + secret in the hosting provider's secret store; never send it in chat or + commit it to this repository. User tokens remain server-side as well. 4. Run `node local-preview.cjs` (Node 22+). Open . - The first click lists repositories; “Authorize & star all” requests a code. - The visitor opens GitHub, enters that code, and grants access. The page then - automatically sends the batch and shows individual results. Retry skips - successful items; existing stars are preserved by GitHub's idempotent PUT. + Visitors click “Authorize on GitHub & star”, sign in if needed, and approve. + GitHub returns them to the homepage, which automatically sends the batch and + shows individual results. No code entry, token copy or extra callback click + is needed. The repository list is expandable before authorization. Retry + skips successful items; existing stars are preserved by GitHub's idempotent PUT. ## Hosting @@ -33,7 +47,10 @@ upstream payloads, or authorization codes. Apply edge request rate limits before public exposure, especially to `/start`. Host the single-process API separately from static file delivery; in-memory sessions are not shared between replicas. -Tokens stay only in server memory, bound to a random HttpOnly/SameSite cookie. +Tokens stay only in server memory, bound to a random HttpOnly/SameSite=Lax cookie. +The web callback checks a session-bound random state and exchanges the code +with S256 PKCE. The callback can be consumed only once; the frontend consumes +an additional one-time resume marker before starting the automatic batch. Sessions expire within 15 minutes; browser close of the dialog discards the session, stopping remaining requests after any in-flight request. Successful stars remain. Server restart requires fresh authorization. No refresh token is @@ -46,5 +63,5 @@ If the service is unavailable or unconfigured, the page shows a clear notice and individual repository links. It never substitutes opening tabs for a successful batch operation. -References: [GitHub App device flow](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-device-flow-to-generate-a-user-access-token), +References: [GitHub App web authorization](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-web-application-flow-to-generate-a-user-access-token), [Starring API and permissions](https://docs.github.com/en/rest/activity/starring#star-a-repository-for-the-authenticated-user). diff --git a/local-preview.cjs b/local-preview.cjs index eb08840..78b1318 100644 --- a/local-preview.cjs +++ b/local-preview.cjs @@ -4,7 +4,8 @@ const fs = require('node:fs'); const path = require('node:path'); const root = __dirname; const {createStarService}=require('./star-service.cjs'); -const starService=createStarService({clientId:process.env.GITHUB_STAR_CLIENT_ID,origin:process.env.STAR_PUBLIC_ORIGIN || `http://127.0.0.1:${process.env.MIRA_PREVIEW_PORT || 4173}`}); +const starDemo=process.argv.includes('--star-demo') || process.env.STAR_LOCAL_DEMO==='1'; +const starService=createStarService({clientId:process.env.GITHUB_STAR_CLIENT_ID,clientSecret:process.env.GITHUB_STAR_CLIENT_SECRET,demo:starDemo,origin:process.env.STAR_PUBLIC_ORIGIN || `http://127.0.0.1:${process.env.MIRA_PREVIEW_PORT || 4173}`}); const types = {'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8','.json':'application/json; charset=utf-8','.svg':'image/svg+xml','.png':'image/png','.webp':'image/webp','.jpg':'image/jpeg','.jpeg':'image/jpeg','.mp4':'video/mp4','.txt':'text/plain; charset=utf-8'}; const server = http.createServer((req,res)=>{ if(req.url.startsWith('/api/stars/')){starService(req,res).catch(()=>{if(!res.headersSent)res.writeHead(500);res.end();});return;} @@ -20,4 +21,4 @@ const server = http.createServer((req,res)=>{ }); }); server.on('error',error=>{console.error(error.message);process.exit(1);}); -server.listen(Number(process.env.MIRA_PREVIEW_PORT || 4173),'127.0.0.1',()=>console.log(`MIRA local preview: http://127.0.0.1:${server.address().port}`)); +server.listen(Number(process.env.MIRA_PREVIEW_PORT || 4173),'127.0.0.1',()=>console.log(`MIRA local preview: http://127.0.0.1:${server.address().port}${starDemo?' (local Star simulation, no GitHub requests)':''}`)); diff --git a/qa-stars.cjs b/qa-stars.cjs index 48a2977..92f9c36 100644 --- a/qa-stars.cjs +++ b/qa-stars.cjs @@ -1,33 +1,28 @@ 'use strict'; -const {test}=require('node:test'); -const assert=require('node:assert/strict'); -const http=require('node:http'); -const fs=require('node:fs'); +const {test}=require('node:test'),assert=require('node:assert/strict'),http=require('node:http'),fs=require('node:fs'),{createHash}=require('node:crypto'); const {createStarService}=require('./star-service.cjs'); -const repos=require('./star-repositories.json'); -const origin='https://example.com'; -async function fixture(t,{configured=true,status=204,pollError=null}={}){ - let time=100000,puts=[],tokenCalls=0; +const repos=require('./star-repositories.json'),origin='https://example.com'; +async function fixture(t,{configured=true,status=204,exchangeError=false,demo=false}={}){ + let time=100000,puts=[],exchanges=[],challenge; const fetcher=async(url,options)=>{ - if(url.endsWith('/device/code'))return Response.json({device_code:'synthetic-device',user_code:'TEST-CODE',interval:5,expires_in:900}); - if(url.endsWith('/access_token')){tokenCalls++;return Response.json(pollError?{error:pollError}:{access_token:'synthetic-token'});} - assert.match(url,/^https:\/\/api.github.com\/user\/starred\//);assert.equal(options.method,'PUT');assert.equal(options.headers.Authorization,'Bearer synthetic-token'); - puts.push(url.split('/starred/')[1]);return new Response(null,{status:typeof status==='function'?status(puts.length):status}); + if(demo)throw Error('Demo must never call GitHub'); + if(url.endsWith('/access_token')){const params=new URLSearchParams(options.body);exchanges.push(params);assert.equal(params.get('client_secret'),'synthetic-secret');assert.equal(params.get('redirect_uri'),origin+'/api/stars/callback');assert.equal(createHash('sha256').update(params.get('code_verifier')).digest('base64url'),challenge);return Response.json(exchangeError?{error:'bad_code'}:{access_token:'synthetic-token'});} + assert.match(url,/^https:\/\/api.github.com\/user\/starred\//);assert.equal(options.method,'PUT');assert.equal(options.headers.Authorization,'Bearer synthetic-token');puts.push(url.split('/starred/')[1]);return new Response(null,{status:typeof status==='function'?status(puts.length):status}); }; - const handler=createStarService({clientId:configured?'synthetic-client':'',origin,fetcher,now:()=>time,pause:async()=>{}}); + const serviceOrigin=demo?'http://127.0.0.1:4173':origin; + const handler=createStarService({clientId:configured?'synthetic-client':'',clientSecret:configured?'synthetic-secret':'',origin:serviceOrigin,fetcher,now:()=>time,pause:async()=>{},demo}); const server=http.createServer((req,res)=>handler(req,res));await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r))); let cookie=''; - const request=async(path,options={})=>{const r=await fetch(`http://127.0.0.1:${server.address().port}/api/stars/${path}`,{method:'POST',headers:{Origin:origin,'X-Requested-With':'InferMatrixStars',Cookie:cookie,...options.headers},...options});const c=r.headers.get('set-cookie');if(c)cookie=c.split(';')[0];return {status:r.status,data:await r.json(),cookie:c};}; - return {request,advance:ms=>time+=ms,puts,tokenCalls:()=>tokenCalls,login:async()=>{await request('start');time+=5000;return request('poll');}}; + const request=async(path,options={})=>{const r=await fetch(`http://127.0.0.1:${server.address().port}/api/stars/${path}`,{method:'POST',redirect:'manual',...options,headers:{Origin:serviceOrigin,'X-Requested-With':'InferMatrixStars',Cookie:cookie,...options.headers}});const c=r.headers.get('set-cookie');if(c)cookie=c.split(';')[0];const raw=await r.text();return {status:r.status,data:r.headers.get('content-type')?.includes('application/json')?JSON.parse(raw):null,text:raw,cookie:c,location:r.headers.get('location')};}; + const start=async()=>{const r=await request('start');const u=new URL(r.data.authorizeUrl);challenge=u.searchParams.get('code_challenge');return {response:r,url:u,state:u.searchParams.get('state')};}; + return {request,start,advance:ms=>time+=ms,puts,exchanges,login:async()=>{const d=await start();return request('callback?state='+d.state+'&code=synthetic-code',{method:'GET'});}}; } -test('catalog matches every homepage GitHub repository, without duplicate case variants',()=>{ - const linked=[...fs.readFileSync('index.html','utf8').matchAll(/https:\/\/github.com\/([\w.-]+\/[\w.-]+)/g)].map(m=>m[1].toLowerCase()); - assert.deepEqual([...new Set(linked)].sort(),repos.map(r=>r.toLowerCase()).sort());assert.equal(new Set(repos.map(r=>r.toLowerCase())).size,repos.length); -}); -test('unconfigured service is explicit and does not authorize',async t=>{const f=await fixture(t,{configured:false});assert.equal((await f.request('status',{method:'GET'})).data.configured,false);assert.equal((await f.request('start')).status,503);}); -test('reject cross-origin and missing custom header; batch requires login',async t=>{const f=await fixture(t);assert.equal((await f.request('start',{headers:{Origin:'https://other.example'}})).status,403);assert.equal((await f.request('start',{headers:{Origin:origin}})).status,403);assert.equal((await f.request('all')).status,401);assert.equal(f.puts.length,0);}); -test('device polling is throttled and tokens never leave the server; retries are idempotent',async t=>{const f=await fixture(t);const start=await f.request('start');assert.match(start.cookie,/HttpOnly; SameSite=Strict; Path=\/; Max-Age=900; Secure/);assert.equal(start.data.device_code,undefined);assert.equal((await f.request('poll')).data.pending,true);assert.equal(f.tokenCalls(),0);f.advance(5000);assert.deepEqual((await f.request('poll')).data,{authorized:true});const batch=await f.request('all');assert.ok(batch.data.results.every(r=>r.ok));assert.deepEqual(f.puts,repos);await f.request('all');assert.equal(f.puts.length,repos.length);assert.ok(!JSON.stringify(batch).includes('synthetic-token'));}); -test('rate limit stops the batch and never reports unattempted stars as success',async t=>{const f=await fixture(t,{status:403});await f.login();const batch=await f.request('all');assert.equal(f.puts.length,1);assert.equal(batch.data.results.length,repos.length);assert.ok(batch.data.results.every(r=>!r.ok));}); -test('partial success retries only failed repositories',async t=>{const f=await fixture(t,{status:n=>n===2?500:204});await f.login();assert.equal((await f.request('all')).data.results.filter(r=>r.ok).length,repos.length-1);await f.request('all');assert.equal(f.puts.length,repos.length+1);assert.equal(f.puts.at(-1),repos[1]);}); -test('slow_down extends the poll interval; denial and expiry clear sessions',async t=>{const f=await fixture(t,{pollError:'slow_down'});await f.request('start');f.advance(5000);assert.equal((await f.request('poll')).data.interval,10);f.advance(5000);await f.request('poll');assert.equal(f.tokenCalls(),1);f.advance(900000);assert.equal((await f.request('poll')).status,401);const g=await fixture(t,{pollError:'access_denied'});await g.request('start');g.advance(5000);assert.equal((await g.request('poll')).status,401);assert.equal((await g.request('all')).status,401);}); -test('logout discards authorization',async t=>{const f=await fixture(t);await f.login();await f.request('logout');assert.equal((await f.request('all')).status,401);assert.equal(f.puts.length,0);}); +test('catalog matches all homepage repositories without duplicates',()=>{const linked=[...fs.readFileSync('index.html','utf8').matchAll(/https:\/\/github.com\/([\w.-]+\/[\w.-]+)/g)].map(m=>m[1].toLowerCase());assert.deepEqual([...new Set(linked)].sort(),repos.map(r=>r.toLowerCase()).sort());assert.equal(new Set(repos.map(r=>r.toLowerCase())).size,repos.length);}); +test('unconfigured service explicitly disables authorization',async t=>{const f=await fixture(t,{configured:false});assert.equal((await f.request('status',{method:'GET'})).data.configured,false);assert.equal((await f.request('start')).status,503);}); +test('cross-origin or missing header cannot start or star',async t=>{const f=await fixture(t);assert.equal((await f.request('start',{headers:{Origin:'https://other.example'}})).status,403);assert.equal((await f.request('start',{headers:{'X-Requested-With':''}})).status,403);assert.equal((await f.request('all')).status,401);assert.equal(f.puts.length,0);}); +test('web authorization uses state, PKCE and server-only credentials; callback resumes once',async t=>{const f=await fixture(t),d=await f.start();assert.match(d.response.cookie,/HttpOnly; SameSite=Lax; Path=\/; Max-Age=900; Secure/);assert.equal(d.url.origin,'https://github.com');assert.equal(d.url.searchParams.get('code_challenge_method'),'S256');assert.ok(!JSON.stringify(d.response).includes('synthetic-secret'));const callback=await f.request('callback?state='+d.state+'&code=synthetic-code',{method:'GET'});assert.equal(callback.location,'/?star=authorized#projects');assert.ok(!JSON.stringify(callback).includes('synthetic-token'));assert.equal((await f.request('resume')).data.authorized,true);assert.equal((await f.request('resume')).data.authorized,false);assert.equal((await f.request('callback?state='+d.state+'&code=synthetic-code',{method:'GET'})).status,400);assert.ok((await f.request('all')).data.results.every(r=>r.ok));assert.deepEqual(f.puts,repos);await f.request('all');assert.equal(f.puts.length,repos.length);}); +test('missing/wrong state or cookie does not exchange code; expired callback fails',async t=>{const f=await fixture(t),d=await f.start();for(const state of ['bad', '💠'.repeat(16),''])assert.equal((await f.request('callback?code=x&state='+encodeURIComponent(state),{method:'GET'})).status,400);assert.equal((await f.request('callback?code=x&state='+d.state,{method:'GET',headers:{Cookie:''}})).status,400);assert.equal(f.exchanges.length,0);f.advance(900001);assert.equal((await f.request('callback?code=x&state='+d.state,{method:'GET'})).status,400);}); +test('denial or failed exchange never grants Star access',async t=>{const f=await fixture(t),d=await f.start();assert.equal((await f.request('callback?state='+d.state+'&error=access_denied',{method:'GET'})).location,'/?star=denied#projects');assert.equal((await f.request('all')).status,401);const g=await fixture(t,{exchangeError:true});assert.equal((await g.login()).location,'/?star=error#projects');assert.equal((await g.request('all')).status,401);}); +test('partial retry skips success and rate limit stops the batch',async t=>{const f=await fixture(t,{status:n=>n===2?500:204});await f.login();assert.equal((await f.request('all')).data.results.filter(r=>r.ok).length,repos.length-1);await f.request('all');assert.equal(f.puts.length,repos.length+1);assert.equal(f.puts.at(-1),repos[1]);const g=await fixture(t,{status:403});await g.login();assert.ok((await g.request('all')).data.results.every(r=>!r.ok));assert.equal(g.puts.length,1);}); +test('logout discards session and tokens',async t=>{const f=await fixture(t);await f.login();await f.request('logout');assert.equal((await f.request('all')).status,401);assert.equal(f.puts.length,0);}); +test('local simulation completes with no GitHub traffic and is prohibited on public origins',async t=>{assert.throws(()=>createStarService({demo:true,origin}),/loopback/);const f=await fixture(t,{demo:true}),d=await f.start();assert.equal(d.url.pathname,'/api/stars/demo-consent');const consent=await f.request('demo-consent?state='+d.state,{method:'GET'});assert.match(consent.text,/不会修改真实 Star/);assert.equal((await f.request('callback?code=synthetic&state='+d.state,{method:'GET'})).status,303);const batch=await f.request('all');assert.equal(batch.data.demo,true);assert.ok(batch.data.results.every(r=>r.ok));assert.equal(f.exchanges.length+f.puts.length,0);}); diff --git a/star-service.cjs b/star-service.cjs index 8a86db8..ee67de9 100644 --- a/star-service.cjs +++ b/star-service.cjs @@ -1,81 +1,89 @@ 'use strict'; -const {randomBytes}=require('node:crypto'); +const {randomBytes,createHash,timingSafeEqual}=require('node:crypto'); const repositories=require('./star-repositories.json'); -// Single-process, short-lived sessions. Never serialize tokens or log upstream responses. -function createStarService({clientId='',origin='http://127.0.0.1:4173',fetcher=fetch,now=Date.now,pause=ms=>new Promise(r=>setTimeout(r,ms))}={}) { +function createStarService({clientId='',clientSecret='',origin='http://127.0.0.1:4173',demo=false,fetcher=fetch,now=Date.now,pause=ms=>new Promise(r=>setTimeout(r,ms))}={}) { + const site=new URL(origin); + if(demo && !['127.0.0.1','localhost','[::1]'].includes(site.hostname))throw Error('Demo is loopback-only'); + const configured=demo || !!(clientId && clientSecret); + const callback=origin+'/api/stars/callback'; const sessions=new Map(); const forget=key=>{clearTimeout(sessions.get(key)?.timer);sessions.delete(key);}; - const cookieName=new URL(origin).protocol==='https:'?'__Host-infermatrix-star':'infermatrix-star'; + const cookieName=site.protocol==='https:'?'__Host-infermatrix-star':'infermatrix-star'; + const matches=(a,b)=>typeof a==='string' && typeof b==='string' && Buffer.byteLength(a)===Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a),Buffer.from(b)); async function upstream(url,options){return fetcher(url,{...options,redirect:'error',signal:AbortSignal.timeout(15000)});} - async function oauth(path,params){const r=await upstream('https://github.com/login/'+path,{method:'POST',headers:{Accept:'application/json','Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams(params)});if(!r.ok)throw Error('upstream');return r.json();} return async function handle(req,res){ - const path=new URL(req.url,origin).pathname; - const send=(status,data)=>{res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store','X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer'});res.end(JSON.stringify(data));}; - if(path==='/api/stars/status' && req.method==='GET'){send(200,{configured:!!clientId,repositories});return;} + const url=new URL(req.url,origin),path=url.pathname; + const headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer'}; + const send=(status,data)=>{res.writeHead(status,{...headers,'Content-Type':'application/json'});res.end(JSON.stringify(data));}; + const redirect=result=>{res.writeHead(303,{...headers,Location:'/?star='+result+'#projects'});res.end();}; + for(const [key,s]of sessions)if(s.expires<=now())forget(key); + const id=(req.headers.cookie||'').split(';').map(c=>c.trim()).find(c=>c.startsWith(cookieName+'='))?.slice(cookieName.length+1); + const session=sessions.get(id); + // Lax is required for GitHub's top-level callback; state + PKCE bind that callback. + const cookie=(value,age)=>res.setHeader('Set-Cookie',`${cookieName}=${value}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${age}${site.protocol==='https:'?'; Secure':''}`); + if(path==='/api/stars/status' && req.method==='GET'){send(200,{configured,demo,repositories});return;} + if(path==='/api/stars/demo-consent' && req.method==='GET'){ + if(!demo || !session || !matches(url.searchParams.get('state'),session.state)){send(404,{error:'not_found'});return;} + const state=session.state; + res.writeHead(200,{...headers,'Content-Type':'text/html; charset=utf-8','Content-Security-Policy':"default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'"}); + res.end(`本地模拟授权

本地模拟 · 不连接 GitHub

允许添加 Star?

模拟为主页列出的 ${repositories.length} 个仓库添加 Star。这里不需要账号、密码或验证码,也不会修改真实 Star。

模拟允许并返回主页取消
`);return; + } + if(path==='/api/stars/callback' && req.method==='GET'){ + if(!session || !matches(url.searchParams.get('state'),session.state)){send(400,{error:'invalid_state'});return;} + if(session.busy){send(409,{error:'busy'});return;} + if(url.searchParams.has('error') || !url.searchParams.get('code')){forget(id);cookie('',0);redirect('denied');return;} + session.busy=true; + try{ + let d; + if(demo)d={access_token:'synthetic-demo-token'}; + else{ + const r=await upstream('https://github.com/login/oauth/access_token',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({client_id:clientId,client_secret:clientSecret,code:url.searchParams.get('code'),redirect_uri:callback,code_verifier:session.verifier})}); + if(!r.ok)throw Error('exchange');d=await r.json(); + } + if(!sessions.has(id)){redirect('expired');return;} + if(!d.access_token)throw Error('exchange'); + session.token=d.access_token;session.ready=true;delete session.state;delete session.verifier; + session.expires=Math.min(session.expires,now()+(Number(d.expires_in)||900)*1000); + redirect('authorized');return; + }catch{forget(id);cookie('',0);redirect('error');return;}finally{session.busy=false;} + } if(req.method!=='POST'){send(405,{error:'method'});return;} if(req.headers.origin!==origin || req.headers['x-requested-with']!=='InferMatrixStars'){send(403,{error:'origin'});return;} - for(const [key,s]of sessions)if(s.expires<=now()&&!s.busy)forget(key); - const id=(req.headers.cookie||'').split(';').map(c=>c.trim()).find(c=>c.startsWith(cookieName+'='))?.slice(cookieName.length+1); - let session=sessions.get(id); - const cookie=(value,age)=>res.setHeader('Set-Cookie',`${cookieName}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${age}${origin.startsWith('https:')?'; Secure':''}`); if(path==='/api/stars/logout'){forget(id);cookie('',0);send(200,{ok:true});return;} - if(!clientId){send(503,{error:'not_configured'});return;} - try { - if(path==='/api/stars/start'){ - if(session){send(409,{error:'session_exists'});return;} - if(sessions.size>=100){send(429,{error:'busy'});return;} - // Reserve capacity before awaiting GitHub, limiting concurrent anonymous starts. - const key=randomBytes(32).toString('hex'); - session={expires:now()+15*60*1000,busy:true,done:new Set()};sessions.set(key,session); - session.timer=setTimeout(()=>forget(key),15*60*1000);session.timer.unref?.(); - let d; - try{d=await oauth('device/code',{client_id:clientId});}catch{forget(key);throw Error('upstream');} - if(d.error || !d.device_code || !d.user_code){forget(key);send(502,{error:'authorization_unavailable'});return;} - session.device=d.device_code;session.interval=Math.max(5,Number(d.interval)||5);session.next=now()+session.interval*1000; - session.expires=now()+Math.min(900,Number(d.expires_in)||900)*1000;session.busy=false; - cookie(key,900);send(200,{userCode:d.user_code,verificationUri:'https://github.com/login/device',interval:session.interval});return; - } - if(!session || session.expires<=now()){send(401,{error:'expired'});return;} - if(session.busy){send(409,{error:'busy'});return;} - if(path==='/api/stars/poll'){ - if(session.token){send(200,{authorized:true});return;} - if(now()r.repository===repository))results.push({repository,ok:session.done.has(repository),status:0}); - send(200,{results});return; - }finally{session.busy=false;} - } - send(404,{error:'not_found'}); - }catch{send(502,{error:'github_unavailable'});} + if(!configured){send(503,{error:'not_configured'});return;} + if(path==='/api/stars/start'){ + if(session){send(409,{error:'session_exists'});return;} + if(sessions.size>=100){send(429,{error:'busy'});return;} + const key=randomBytes(32).toString('hex'),state=randomBytes(32).toString('hex'),verifier=randomBytes(48).toString('base64url'); + const s={expires:now()+15*60*1000,state,verifier,busy:false,done:new Set()};sessions.set(key,s); + s.timer=setTimeout(()=>forget(key),15*60*1000);s.timer.unref?.();cookie(key,900); + const authorize=new URL(demo?origin+'/api/stars/demo-consent':'https://github.com/login/oauth/authorize'); + authorize.search=new URLSearchParams(demo?{state}:{client_id:clientId,redirect_uri:callback,state,code_challenge:createHash('sha256').update(verifier).digest('base64url'),code_challenge_method:'S256'}).toString(); + send(200,{authorizeUrl:authorize.href});return; + } + if(!session){send(401,{error:'expired'});return;} + if(session.busy){send(409,{error:'busy'});return;} + if(path==='/api/stars/resume'){const authorized=!!(session.token&&session.ready);session.ready=false;send(200,{authorized});return;} + if(path==='/api/stars/all'){ + if(!session.token){send(401,{error:'authorize_first'});return;} + session.busy=true;const results=[]; + try{ + for(const repository of repositories){ + if(!sessions.has(id) || session.expires<=now())break; + if(session.done.has(repository)){results.push({repository,ok:true});continue;} + let status=0; + try{status=demo?204:(await upstream('https://api.github.com/user/starred/'+repository,{method:'PUT',headers:{Authorization:'Bearer '+session.token,Accept:'application/vnd.github+json','X-GitHub-Api-Version':'2026-03-10','User-Agent':'InferMatrix-Stars'}})).status;}catch{/* Neutral error only. */} + const ok=status===204 || status===304;if(ok)session.done.add(repository); + results.push({repository,ok,status}); + if([401,403,429].includes(status)){if(status===401){delete session.token;session.expires=now();}break;} + await pause(demo?150:1000); + } + for(const repository of repositories)if(!results.some(r=>r.repository===repository))results.push({repository,ok:session.done.has(repository),status:0}); + send(200,{results,demo});return; + }finally{session.busy=false;} + } + send(404,{error:'not_found'}); }; } module.exports={createStarService}; diff --git a/stars.css b/stars.css index 323b73d..738970d 100644 --- a/stars.css +++ b/stars.css @@ -9,8 +9,9 @@ .star-all-list a { color: #9fdae4; } .star-all-list span { font-family: var(--font); } .star-ok { color: #8be5be; }.star-error { color: #f1c58f; } -.star-all-auth { padding: 15px; border: 1px solid #45627c; border-radius: 10px; margin-bottom: 18px; } -.star-all-code { display: block; font: 26px/2 var(--mono); letter-spacing: 3px; color: #fff; user-select: all; } +.star-all-dialog summary { cursor: pointer; color: #9fdae4; padding: 10px 0; } +.star-demo-banner { position: sticky; top: 0; z-index: 50; padding: 8px 18px; background: #503816; color: #ffe2b3; font-size: 13px; text-align: center; } +.star-demo-banner[hidden] { display: none; } .star-all-submit { cursor: pointer; white-space: normal; }.star-all-submit:disabled { opacity: .55; cursor: default; } .star-all-dialog :focus-visible { outline: 2px solid #8de5ed; outline-offset: 3px; } @media(max-width: 480px) { .star-all-dialog { padding: 20px; } } diff --git a/stars.js b/stars.js index 74233d1..887756d 100644 --- a/stars.js +++ b/stars.js @@ -3,28 +3,46 @@ const zh=()=>document.documentElement.lang.startsWith('zh'); const copy=(a,b)=>zh()?a:b; const entry=document.createElement('button');entry.className='star-all-entry button button-secondary';entry.type='button'; - document.querySelector('#projects .section-heading')?.append(entry); - if(!entry.isConnected)document.querySelector('#projects .project-filters, #projects .filters')?.before(entry); - if(!entry.isConnected)document.querySelector('#projects').prepend(entry); + document.querySelector('#projects .section-heading').append(entry); + const banner=document.createElement('div');banner.className='star-demo-banner';banner.hidden=true;document.body.prepend(banner); const dialog=document.createElement('dialog');dialog.className='star-all-dialog';dialog.setAttribute('aria-labelledby','star-all-title'); - dialog.innerHTML='

    '; + dialog.innerHTML='

      '; document.body.append(dialog); const $=s=>dialog.querySelector(s),button=$('.star-all-submit'),status=$('.star-all-status'); - let configured=false,busy=false,authorized=false,timer=null,generation=0,catalog=[],results=null; + let configured=false,demo=false,busy=false,authorized=false,generation=0,catalog=[],results=null,phase='loading'; const request=async path=>{const r=await fetch('/api/stars/'+path,{method:'POST',credentials:'same-origin',headers:{'X-Requested-With':'InferMatrixStars'}});const d=await r.json();if(!r.ok)throw Error(d.error||'unavailable');return d;}; let authorizationRequests=Promise.resolve(); - // Order cookie-changing requests so a late start cannot replace a newer session. - const api=path=>{if(path==='all')return request(path);const operation=authorizationRequests.then(()=>request(path));authorizationRequests=operation.catch(()=>{});return operation;}; - function translate(){entry.textContent=copy('☆ 一键 Star 全部仓库','☆ Star all repositories');$('.star-all-close').setAttribute('aria-label',copy('关闭 Star 窗口','Close Star dialog'));$('#star-all-title').textContent=copy('支持 InferMatrix 开源生态','Support the InferMatrix ecosystem');$('.star-all-description').textContent=copy(`为主页展示的 ${catalog.length} 个 GitHub 仓库添加 Star。首次需在 GitHub 登录并授权,授权后自动完成;已经 Star 的仓库保持不变。`,`Star all ${catalog.length} GitHub repositories linked on this homepage. First sign in and authorize on GitHub; the batch then runs automatically. Existing stars stay starred.`);button.textContent=busy?copy('正在处理…','Working…'):authorized?copy('重试未成功的仓库','Retry unsuccessful repositories'):copy('授权并一键 Star','Authorize & star all');$('.star-all-auth p').textContent=copy('在 GitHub 输入此验证码,确认仅授予 Star 权限:','Enter this code on GitHub and review the Starring permission:');$('.star-all-auth a').textContent=copy('前往 GitHub 授权 ↗','Authorize on GitHub ↗');renderList();} - function renderList(){const list=$('.star-all-list');list.replaceChildren();for(const repository of catalog){const li=document.createElement('li'),a=document.createElement('a');a.href='https://github.com/'+repository;a.target='_blank';a.rel='noopener noreferrer';a.textContent=repository;li.append(a);const r=results?.find(x=>x.repository===repository);if(r){const label=document.createElement('span');label.textContent=r.ok?copy('已 Star ✓','Starred ✓'):copy('未成功 · 可重试','Unsuccessful · retry');label.className=r.ok?'star-ok':'star-error';li.append(label);}list.append(li);}} - function reset(){clearTimeout(timer);generation++;busy=false;authorized=false;results=null;$('.star-all-auth').hidden=true;button.disabled=!configured;translate();} - async function load(){reset();status.textContent=copy('正在获取仓库清单…','Loading repositories…');try{const r=await fetch('/api/stars/status',{cache:'no-store'});if(!r.ok)throw Error();const d=await r.json();configured=d.configured;catalog=d.repositories;}catch{configured=false;try{catalog=await (await fetch('star-repositories.json')).json();}catch{catalog=[];}}button.disabled=!configured;status.textContent=configured?copy('仅操作以下仓库,GitHub 访问令牌仅保存在服务端。','Only the repositories below will be starred. GitHub access tokens stay on the server.'):copy('批量 Star 服务尚未配置。你可以先通过清单中的链接访问各仓库。','Bulk Star is not configured yet. You can visit each repository below.');translate();} - async function star(run){status.textContent=copy('正在逐个添加 Star,请稍候…','Starring repositories, please wait…');const d=await api('all');if(run!==generation)return;results=d.results;const count=results.filter(r=>r.ok).length;status.textContent=copy(`已 Star ${count} / ${catalog.length} 个仓库。`,`Starred ${count} / ${catalog.length} repositories.`);busy=false;button.disabled=count===catalog.length;translate();} - async function poll(run,interval){timer=setTimeout(async()=>{if(run!==generation)return;try{const d=await api('poll');if(run!==generation)return;if(d.authorized){authorized=true;$('.star-all-auth').hidden=true;await star(run);}else poll(run,d.interval||interval);}catch{if(run===generation)fail();}},interval*1000);} - function fail(){clearTimeout(timer);busy=false;status.textContent=copy('操作未完成,可能是授权过期、权限不足或 GitHub 暂时不可用。请关闭窗口后重试;已添加的 Star 会保留。','Not completed: authorization may have expired, permissions may be missing, or GitHub may be unavailable. Close and reopen to retry; completed stars are retained.');button.disabled=true;translate();} - entry.addEventListener('click',()=>{dialog.showModal();load();}); - button.addEventListener('click',async()=>{if(busy)return;busy=true;button.disabled=true;translate();const run=generation;try{if(authorized){await star(run);return;}await api('logout');if(run!==generation)return;const d=await api('start');if(run!==generation)return;$('.star-all-code').textContent=d.userCode;$('.star-all-auth').hidden=false;status.textContent=copy('等待 GitHub 授权;完成后会自动 Star 上方全部仓库。','Waiting for GitHub authorization; all repositories above will then be starred automatically.');poll(run,d.interval);}catch{if(run===generation)fail();}}); + const api=path=>{if(path==='all')return request(path);const op=authorizationRequests.then(()=>request(path));authorizationRequests=op.catch(()=>{});return op;}; + function translate(){ + entry.textContent=copy('☆ 一键 Star 全部仓库','☆ Star all repositories'); + banner.hidden=!demo;banner.textContent=copy('本地模拟测试 · 不会登录 GitHub 或修改真实 Star','Local simulation · no GitHub login or real stars'); + $('.star-all-close').setAttribute('aria-label',copy('关闭 Star 窗口','Close Star dialog')); + $('#star-all-title').textContent=copy('支持 InferMatrix 开源生态','Support the InferMatrix ecosystem'); + $('.star-all-description').textContent=copy(`一次支持主页展示的 ${catalog.length} 个仓库。前往 GitHub 登录并授权,返回后自动完成。已添加的 Star 保持不变。`,`Support all ${catalog.length} repositories. Sign in and authorize on GitHub; stars are added automatically when you return. Existing stars stay starred.`); + $('summary').textContent=copy(`查看全部 ${catalog.length} 个仓库`,`View all ${catalog.length} repositories`); + button.textContent=busy?copy('正在处理…','Working…'):phase==='done'&&results?.every(r=>r.ok)?copy(demo?'模拟体验完成 ✓':'已完成 ✓',demo?'Simulation complete ✓':'Complete ✓'):authorized?copy('重试未成功的仓库','Retry unsuccessful repositories'):demo?copy('体验模拟授权 →','Try simulated authorization →'):copy('前往 GitHub 授权并 Star →','Authorize on GitHub & star →'); + const messages={loading:copy('正在加载…','Loading…'),ready:demo?copy('模拟流程:确认授权 → 自动返回 → 展示结果。不会产生真实 Star。','Simulation: approve → return → view results. No real stars are added.'):copy('只需在 GitHub 确认一次,无需复制验证码或提供访问令牌。','Approve on GitHub. No verification code or access token to copy.'),unconfigured:copy('授权服务尚未配置。你仍可展开清单访问各仓库。','Authorization is not configured. Expand the list to visit the repositories.'),authorizing:copy('正在前往授权页面…','Opening authorization…'),resuming:copy('正在恢复授权,请稍候…','Restoring authorization, please wait…'),starring:copy('正在逐个添加 Star,请稍候…','Starring repositories, please wait…'),error:copy('操作未完成,请重试。已添加的 Star 会保留。','Not completed. Retry to continue; completed stars are retained.'),denied:copy('已取消授权,没有执行新的 Star 操作。','Authorization cancelled. No new stars were added.'),expired:copy('授权已过期,请重新授权。','Authorization expired. Please authorize again.'),done:copy(`${demo?'模拟结果:':''}已 Star ${results?.filter(r=>r.ok).length||0} / ${catalog.length} 个仓库。`,`${demo?'Simulated result: ':''}Starred ${results?.filter(r=>r.ok).length||0} / ${catalog.length} repositories.`)}; + status.textContent=messages[phase]||messages.error;renderList(); + } + function renderList(){const list=$('.star-all-list');list.replaceChildren();for(const repository of catalog){const li=document.createElement('li'),a=document.createElement('a');a.href='https://github.com/'+repository;a.target='_blank';a.rel='noopener noreferrer';a.textContent=repository;li.append(a);const r=results?.find(x=>x.repository===repository);if(r){const label=document.createElement('span');label.textContent=r.ok?copy(demo?'模拟成功 ✓':'已 Star ✓',demo?'Simulated ✓':'Starred ✓'):copy('未成功 · 可重试','Unsuccessful · retry');label.className=r.ok?'star-ok':'star-error';li.append(label);}list.append(li);}} + async function configuration(){try{const r=await fetch('/api/stars/status',{cache:'no-store'});if(!r.ok)throw Error();const d=await r.json();configured=d.configured;demo=d.demo===true;catalog=d.repositories;}catch{configured=false;demo=false;try{catalog=await (await fetch('star-repositories.json')).json();}catch{catalog=[];}}} + async function open(resuming=false){const run=++generation;busy=true;phase='loading';authorized=false;results=null;$('details').open=false;button.disabled=true;dialog.showModal();translate();await configuration();if(run!==generation)return null;busy=resuming;phase=resuming?'resuming':configured?'ready':'unconfigured';button.disabled=resuming || !configured;translate();return run;} + async function star(run){phase='starring';busy=true;button.disabled=true;translate();const d=await api('all');if(run!==generation)return;results=d.results;const expired=results.some(r=>r.status===401);if(expired)authorized=false;phase=expired?'expired':'done';busy=false;button.disabled=results.every(r=>r.ok);$('details').open=true;translate();} + function fail(error){const expired=['expired','authorize_first'].includes(error?.message);if(expired)authorized=false;phase=expired?'expired':'error';busy=false;button.disabled=!configured;translate();} + entry.addEventListener('click',()=>{open();}); + button.addEventListener('click',async()=>{ + if(busy)return;busy=true;button.disabled=true;phase='authorizing';translate();const run=generation; + try{if(authorized){await star(run);return;}await api('logout');if(run!==generation)return;const d=await api('start');if(run!==generation)return; + const destination=new URL(d.authorizeUrl); + if(demo ? destination.origin!==location.origin || destination.pathname!=='/api/stars/demo-consent' : destination.origin!=='https://github.com' || destination.pathname!=='/login/oauth/authorize')throw Error('authorization_url'); + location.assign(destination.href); + }catch(error){if(run===generation)fail(error);} + }); $('.star-all-close').addEventListener('click',()=>dialog.close()); - dialog.addEventListener('close',()=>{reset();api('logout').catch(()=>{});entry.focus();}); - new MutationObserver(translate).observe(document.documentElement,{attributes:true,attributeFilter:['lang']});translate(); + dialog.addEventListener('close',()=>{generation++;busy=false;authorized=false;results=null;api('logout').catch(()=>{});entry.focus();}); + new MutationObserver(translate).observe(document.documentElement,{attributes:true,attributeFilter:['lang']}); + const returned=new URL(location.href),result=returned.searchParams.get('star'); + if(result){returned.searchParams.delete('star');history.replaceState(null,'',returned.pathname+returned.search+returned.hash);open(result==='authorized').then(async run=>{if(run===null || run!==generation)return;try{if(result==='authorized'){const d=await api('resume');if(run!==generation)return;if(!d.authorized){busy=false;button.disabled=!configured;phase='expired';translate();return;}authorized=true;await star(run);}else{phase=['denied','expired'].includes(result)?result:'error';translate();}}catch(error){if(run===generation)fail(error);}});} + else configuration().then(translate); + translate(); })();