Skip to content

Commit 1b1f51d

Browse files
Fix Community share links and Universal Links
1 parent 6ef7f5e commit 1b1f51d

12 files changed

Lines changed: 633 additions & 3 deletions

.well-known/apple-app-site-association

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
"/": "/s/*",
1111
"comment": "Open a community script or MiniApp work."
1212
},
13+
{
14+
"/": "/community/*",
15+
"comment": "Open a Community V2 post or comment."
16+
},
1317
{
1418
"/": "/import",
1519
"comment": "Open a remote import preview."

README.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
This directory is the static GitHub Pages origin for:
44

55
- `https://link.pythonide.xin/s/{scriptID}` community work shares
6+
- `https://link.pythonide.xin/community/{postID}` Community V2 post and comment shares
67
- `https://link.pythonide.xin/l/{code}` reserved short links
78
- `https://link.pythonide.xin/import?url=...` remote import links
89
- `https://link.pythonide.xin/.well-known/apple-app-site-association` iOS Universal Links
910
- `https://link.pythonide.xin/mcp-oauth/client.json` MCP OAuth Client ID Metadata Document
1011
- `https://link.pythonide.xin/mcp-oauth/callback` MCP OAuth HTTPS callback
12+
- `https://link.pythonide.xin/ai-oauth/client.json` custom AI OAuth Client ID Metadata Document
13+
- `https://link.pythonide.xin/ai-oauth/callback` custom AI OAuth HTTPS callback
1114

1215
## Repository and deployment
1316

@@ -44,7 +47,10 @@ migration or community API change is required.
4447
Before either deployment, run:
4548

4649
```bash
47-
node --test link-site/tests/share-page.test.js link-edge/tests/worker.test.js
50+
node --test \
51+
link-site/tests/share-page.test.js \
52+
link-site/tests/aasa-deployment.test.js \
53+
link-edge/tests/worker.test.js
4854
```
4955

5056
## iOS Requirement
@@ -56,14 +62,32 @@ applinks:link.pythonide.xin
5662
webcredentials:link.pythonide.xin
5763
```
5864

59-
The AASA file currently registers `/s/*` and `/import`. Keep `/l/*` out of AASA until the short-link resolver backend is connected, so unfinished short links still open the web fallback instead of launching the app with no resolved target.
65+
The AASA file registers `/s/*`, `/community/*`, and `/import`. Keep `/l/*` out of AASA until the short-link resolver backend is connected, so unfinished short links still open the web fallback instead of launching the app with no resolved target.
6066

6167
The AASA file must be reachable without redirects:
6268

6369
```text
6470
https://link.pythonide.xin/.well-known/apple-app-site-association
6571
```
6672

73+
The local check is network-free by default:
74+
75+
```bash
76+
node link-site/scripts/check-aasa-deployment.mjs --plan
77+
```
78+
79+
After Pages has deployed, create the production release attestation with an explicit live opt-in:
80+
81+
```bash
82+
node link-site/scripts/check-aasa-deployment.mjs \
83+
--verify-live \
84+
--environment production \
85+
--confirm-domain link.pythonide.xin \
86+
--output /controlled-temporary-directory/aasa-attestation.json
87+
```
88+
89+
The verifier requests both the no-redirect origin URL and Apple's AASA CDN, requires HTTP 200 with `application/json`, validates the app ID plus `/community/*`, and compares their semantic JSON digests with the reviewed local file. The output contains only URLs, status/content-type, timestamps, and digests. It never contains credentials. Community's production release gate rejects missing or older-than-24-hour attestations.
90+
6791
## MCP OAuth
6892

6993
The app and hosted files share one callback contract:
@@ -82,3 +106,16 @@ the canonical HTTPS URL before the official MCP SDK validates state and PKCE.
82106
Both MCP OAuth files and the AASA file must be deployed together. Do not add
83107
the OAuth callback path to `applinks`; the compatibility page must remain
84108
loadable on iOS versions earlier than 17.4.
109+
110+
## Custom AI OAuth
111+
112+
Custom AI connections use a separate callback and credential namespace:
113+
114+
```text
115+
Client ID: https://link.pythonide.xin/ai-oauth/client.json
116+
Redirect URI: https://link.pythonide.xin/ai-oauth/callback
117+
```
118+
119+
On iOS 16.2–17.3, the callback page forwards the query losslessly to
120+
`pythonide://oauth/custom-ai`. Keep the AI and MCP paths separate even though
121+
they intentionally share the same visual treatment.

assets/share-page.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@
7272
previewUnavailable: '预览暂不可用',
7373
previewUnavailableBody: '使用下方按钮在 PythonIDE 中打开这个作品。',
7474
community: '社区作品',
75+
communityPost: '社区帖子',
76+
communityPostSummary: '在 PythonIDE 中查看完整内容、评论和互动。',
77+
targetComment: '目标评论',
7578
remoteImport: '远程导入',
7679
importRemoteProject: '导入远程项目',
7780
missingProjectURL: '缺少项目地址',
@@ -156,6 +159,9 @@
156159
previewUnavailable: 'Preview unavailable',
157160
previewUnavailableBody: 'Use the buttons below to open this work in PythonIDE.',
158161
community: 'Community work',
162+
communityPost: 'Community post',
163+
communityPostSummary: 'View the full post, comments, and interactions in PythonIDE.',
164+
targetComment: 'Target comment',
159165
remoteImport: 'Remote import',
160166
importRemoteProject: 'Import remote project',
161167
missingProjectURL: 'Project URL missing',
@@ -232,9 +238,48 @@
232238
return String(navigatorLanguage || '').toLowerCase().startsWith('zh') ? 'zh' : 'en';
233239
}
234240

241+
function normalizedCommunityIdentifier(value) {
242+
const normalized = String(value || '');
243+
return normalized.length <= 160 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalized)
244+
? normalized
245+
: '';
246+
}
247+
248+
function parseCommunityRoute(url) {
249+
if (url.hash) return null;
250+
const pathParts = url.pathname.split('/');
251+
if (pathParts.length !== 3 || pathParts[0] !== '' || pathParts[1] !== 'community') return null;
252+
253+
const postID = normalizedCommunityIdentifier(decodeSegment(pathParts[2]));
254+
if (!postID) return null;
255+
256+
const allowedQueryNames = new Set(['commentID', 'lang', 'v']);
257+
for (const name of url.searchParams.keys()) {
258+
if (!allowedQueryNames.has(name) || url.searchParams.getAll(name).length !== 1) return null;
259+
}
260+
if (url.searchParams.has('lang') && !['zh', 'en'].includes(url.searchParams.get('lang'))) return null;
261+
262+
const rawCommentID = url.searchParams.get('commentID');
263+
const commentID = rawCommentID === null ? '' : normalizedCommunityIdentifier(rawCommentID);
264+
if (rawCommentID !== null && !commentID) return null;
265+
266+
const canonicalPath = `/community/${encodeURIComponent(postID)}`;
267+
return {
268+
type: 'community',
269+
postID,
270+
commentID,
271+
path: commentID
272+
? `${canonicalPath}?commentID=${encodeURIComponent(commentID)}`
273+
: canonicalPath,
274+
};
275+
}
276+
235277
function parseRoutePath(value) {
236278
const path = safeForwardedPath(value) || '/';
237279
const url = new URL(path, SITE_ORIGIN);
280+
if (url.pathname === '/community' || url.pathname.startsWith('/community/')) {
281+
return parseCommunityRoute(url) || { type: 'home', path: '/' };
282+
}
238283
const parts = url.pathname.split('/').filter(Boolean);
239284
if (parts[0] === 's' && parts[1]) {
240285
return { type: 'script', id: decodeSegment(parts[1]), path: url.pathname };
@@ -261,6 +306,12 @@
261306
}
262307

263308
function customURLFor(route) {
309+
if (route.type === 'community') {
310+
const path = `pythonide://community/post/${encodeURIComponent(route.postID)}`;
311+
return route.commentID
312+
? `${path}?commentID=${encodeURIComponent(route.commentID)}`
313+
: path;
314+
}
264315
if (route.type === 'script') {
265316
return `pythonide://community/script?id=${encodeURIComponent(route.id)}`;
266317
}
@@ -424,6 +475,7 @@
424475
formatCount,
425476
isProjectScript,
426477
normalizedTags,
478+
normalizedCommunityIdentifier,
427479
parseRoutePath,
428480
previewLines,
429481
preferredLanguage,
@@ -932,6 +984,29 @@
932984
setLoading(false);
933985
}
934986

987+
function loadCommunity() {
988+
currentView = 'community';
989+
currentScript = null;
990+
el.authorRow.hidden = true;
991+
el.openApp.disabled = false;
992+
setText(el.openAppLabel, tr('openApp'));
993+
hideStatus();
994+
setText(el.eyebrow, `PythonIDE · ${tr('community')}`);
995+
setText(el.title, tr('communityPost'));
996+
setText(el.summary, tr('communityPostSummary'));
997+
renderGeneric(`PythonIDE · ${tr('communityPost')}`, '#');
998+
renderStats([
999+
{ value: tr('readOnlyValue'), label: tr('preview') },
1000+
{ value: 'App', label: tr('openMethod') },
1001+
{ value: route.commentID ? '1' : '—', label: tr('targetComment') },
1002+
{ value: tr('safeValue'), label: tr('linkStructure') },
1003+
]);
1004+
setText(el.actionTitle, tr('openApp'));
1005+
setText(el.actionDescription, tr('communityPostSummary'));
1006+
updatePageMetadata(tr('communityPost'), tr('communityPostSummary'), DEFAULT_SHARE_IMAGE);
1007+
setLoading(false);
1008+
}
1009+
9351010
function loadHome() {
9361011
currentView = 'home';
9371012
currentScript = null;
@@ -1022,6 +1097,7 @@
10221097
if (currentView === 'script-error') renderScriptError();
10231098
else if (currentView === 'import') loadImport();
10241099
else if (currentView === 'short') loadShort();
1100+
else if (currentView === 'community') loadCommunity();
10251101
else if (currentView === 'home') loadHome();
10261102
}
10271103

@@ -1059,5 +1135,6 @@
10591135
if (route.type === 'script') loadScript(initialScriptData(route.id));
10601136
else if (route.type === 'import') loadImport();
10611137
else if (route.type === 'short') loadShort();
1138+
else if (route.type === 'community') loadCommunity();
10621139
else loadHome();
10631140
}(typeof globalThis !== 'undefined' ? globalThis : this));

edge/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
This worker adds server-rendered Open Graph metadata and dynamic PNG preview
44
cards to the existing `link.pythonide.xin` GitHub Pages site. It does not change
5-
the community API and only intercepts `/s/*` and `/og/*`.
5+
the community API and only intercepts the AASA endpoint, `/s/*`, and `/og/*`.
6+
7+
The AASA route serves the reviewed association document directly with an
8+
`application/json` content type. Keep it semantically equivalent to
9+
`../link-site/.well-known/apple-app-site-association`; the worker test enforces
10+
that contract before deployment.
611

712
The worker fetches the published `index.html` from the `pythonide-link`
813
repository, reads public script details from the existing community API, and

edge/tests/worker.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
34
import {
5+
AASA_DOCUMENT,
6+
handleAssociationFile,
47
handleSharePage,
58
injectInitialScriptData,
69
injectMetadata,
@@ -11,6 +14,31 @@ import {
1114
workPresentation,
1215
} from '../worker.js';
1316

17+
test('serves the reviewed AASA document with the required JSON content type', async () => {
18+
const reviewedDocument = JSON.parse(fs.readFileSync(
19+
new URL('../../link-site/.well-known/apple-app-site-association', import.meta.url),
20+
'utf8',
21+
));
22+
const response = handleAssociationFile(new Request(
23+
'https://link.pythonide.xin/.well-known/apple-app-site-association',
24+
));
25+
26+
assert.equal(response.status, 200);
27+
assert.match(response.headers.get('content-type'), /^application\/json\b/);
28+
assert.deepEqual(AASA_DOCUMENT, reviewedDocument);
29+
assert.deepEqual(await response.json(), reviewedDocument);
30+
});
31+
32+
test('serves AASA HEAD requests without a response body', async () => {
33+
const response = handleAssociationFile(new Request(
34+
'https://link.pythonide.xin/.well-known/apple-app-site-association',
35+
{ method: 'HEAD' },
36+
));
37+
38+
assert.equal(response.status, 200);
39+
assert.equal(await response.text(), '');
40+
});
41+
1442
test('embeds safe first-paint work data into generated pages', () => {
1543
const template = '<script id="initial-script-data" type="application/json">{}</script>';
1644
const html = injectInitialScriptData(template, { script_id: 'scr_1', title: '$1</script><b>unsafe</b>' });

edge/worker.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ const DEFAULT_INDEX_URL = 'https://raw.githubusercontent.com/Python-IDE/pythonid
44
const DEFAULT_IMAGE = `${SITE_ORIGIN}/assets/app-icon.png`;
55
const DEFAULT_TITLE = 'Python IDE 社区作品';
66
const DEFAULT_DESCRIPTION = '在 Python IDE 中查看、运行和导入社区作品。';
7+
const AASA_DOCUMENT = {
8+
applinks: {
9+
details: [
10+
{
11+
appIDs: ['8GYAXFCC2W.app.pythonide'],
12+
components: [
13+
{ '/': '/s/*', comment: 'Open a community script or MiniApp work.' },
14+
{ '/': '/community/*', comment: 'Open a Community V2 post or comment.' },
15+
{ '/': '/import', comment: 'Open a remote import preview.' },
16+
],
17+
},
18+
],
19+
},
20+
webcredentials: {
21+
apps: ['8GYAXFCC2W.app.pythonide'],
22+
},
23+
};
24+
const AASA_JSON = JSON.stringify(AASA_DOCUMENT);
725

826
const FONT = {
927
' ': ['00000','00000','00000','00000','00000','00000','00000'],
@@ -470,9 +488,22 @@ async function handleOGImage(scriptId, fetcher = fetch) {
470488
}
471489
}
472490

491+
function handleAssociationFile(request) {
492+
return new Response(request.method === 'HEAD' ? null : AASA_JSON, {
493+
status: 200,
494+
headers: {
495+
'Content-Type': 'application/json; charset=utf-8',
496+
'Cache-Control': 'public, max-age=300, s-maxage=3600, must-revalidate',
497+
'X-Content-Type-Options': 'nosniff',
498+
},
499+
});
500+
}
501+
473502
export {
503+
AASA_DOCUMENT,
474504
buildMetaBlock,
475505
encodePNG,
506+
handleAssociationFile,
476507
handleOGImage,
477508
handleSharePage,
478509
injectInitialScriptData,
@@ -490,6 +521,9 @@ export default {
490521
return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } });
491522
}
492523
const url = new URL(request.url);
524+
if (url.pathname === '/.well-known/apple-app-site-association') {
525+
return handleAssociationFile(request);
526+
}
493527
const shareMatch = /^\/s\/([^/]+)\/?$/.exec(url.pathname);
494528
if (shareMatch) return handleSharePage(request, decodeSegment(shareMatch[1]), env);
495529
const imageMatch = /^\/og\/script\/([^/]+)\.png$/.exec(url.pathname);

edge/wrangler.jsonc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
"main": "worker.js",
55
"compatibility_date": "2026-07-01",
66
"routes": [
7+
{
8+
"pattern": "link.pythonide.xin/.well-known/apple-app-site-association",
9+
"zone_name": "pythonide.xin"
10+
},
711
{
812
"pattern": "link.pythonide.xin/s/*",
913
"zone_name": "pythonide.xin"

0 commit comments

Comments
 (0)