Skip to content

Commit f85311f

Browse files
committed
Add "accept EULA" content
Resolves wixtoolset issue 9196
1 parent fbc951d commit f85311f

6 files changed

Lines changed: 614 additions & 4 deletions

File tree

astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export default defineConfig({
6363

6464
{ label: 'WiX Toolset', collapsed: true, items: [
6565
'wix',
66+
'wix/osmf',
6667
'wix/using-wix',
6768
'wix/gethelp',
6869

functions/api/osmf/callback.js

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
const DEFAULT_RETURN_PATH = "/wix/osmf/";
2+
const STATE_COOKIE = "osmf_state";
3+
const RETURN_COOKIE = "osmf_return";
4+
const SPONSOR_LOGIN = "wixtoolset";
5+
const YEAR_MS = 365 * 24 * 60 * 60 * 1000;
6+
7+
function safeDecode(value) {
8+
try {
9+
return decodeURIComponent(value);
10+
} catch {
11+
return value;
12+
}
13+
}
14+
15+
function normalizeReturnPath(value, fallback) {
16+
if (!value) return fallback;
17+
const decoded = safeDecode(value);
18+
if (!decoded.startsWith("/")) return fallback;
19+
if (decoded.startsWith("//")) return fallback;
20+
if (decoded.includes("://")) return fallback;
21+
return decoded;
22+
}
23+
24+
function parseCookies(header) {
25+
const cookies = {};
26+
if (!header) return cookies;
27+
const parts = header.split(";").map((part) => part.trim());
28+
for (const part of parts) {
29+
const [name, ...rest] = part.split("=");
30+
if (!name) continue;
31+
cookies[name] = safeDecode(rest.join("="));
32+
}
33+
return cookies;
34+
}
35+
36+
function buildSetCookie(name, value, { maxAgeSeconds, secure }) {
37+
const parts = [
38+
`${name}=${encodeURIComponent(value)}`,
39+
"Path=/",
40+
"HttpOnly",
41+
"SameSite=Lax",
42+
];
43+
if (typeof maxAgeSeconds === "number") {
44+
parts.push(`Max-Age=${maxAgeSeconds}`);
45+
}
46+
if (secure) {
47+
parts.push("Secure");
48+
}
49+
return parts.join("; ");
50+
}
51+
52+
function clearCookie(name, secure) {
53+
return buildSetCookie(name, "", { maxAgeSeconds: 0, secure });
54+
}
55+
56+
function buildReturnUrl(origin, returnPath, params) {
57+
const destination = new URL(returnPath, origin);
58+
for (const [key, value] of Object.entries(params)) {
59+
if (value !== undefined && value !== null) {
60+
destination.searchParams.set(key, value);
61+
}
62+
}
63+
return destination.toString();
64+
}
65+
66+
async function exchangeToken({ clientId, clientSecret, code, redirectUri }) {
67+
const response = await fetch("https://github.com/login/oauth/access_token", {
68+
method: "POST",
69+
headers: {
70+
Accept: "application/json",
71+
"Content-Type": "application/json",
72+
},
73+
body: JSON.stringify({
74+
client_id: clientId,
75+
client_secret: clientSecret,
76+
code,
77+
redirect_uri: redirectUri,
78+
}),
79+
});
80+
81+
if (!response.ok) {
82+
throw new Error("token_exchange_failed");
83+
}
84+
85+
const payload = await response.json();
86+
if (!payload.access_token) {
87+
throw new Error("token_missing");
88+
}
89+
return payload.access_token;
90+
}
91+
92+
async function githubGraphQL(token, query, variables) {
93+
const response = await fetch("https://api.github.com/graphql", {
94+
method: "POST",
95+
headers: {
96+
Authorization: `Bearer ${token}`,
97+
"Content-Type": "application/json",
98+
},
99+
body: JSON.stringify({ query, variables }),
100+
});
101+
102+
if (!response.ok) {
103+
throw new Error("github_graphql_failed");
104+
}
105+
106+
const payload = await response.json();
107+
if (payload.errors?.length) {
108+
throw new Error("github_graphql_error");
109+
}
110+
return payload.data;
111+
}
112+
113+
function parseIsoDate(value) {
114+
const timestamp = Date.parse(value);
115+
return Number.isFinite(timestamp) ? timestamp : null;
116+
}
117+
118+
function pickSponsorshipResult(nodes) {
119+
const now = Date.now();
120+
let latestOneTime = null;
121+
122+
for (const node of nodes ?? []) {
123+
if (!node) continue;
124+
125+
// Any active recurring sponsorship is considered valid immediately.
126+
if (node.isActive) {
127+
return { coverage: "active" };
128+
}
129+
130+
// Treat a one-time sponsorship as valid for 1 year from its creation date.
131+
if (!node.tier?.isOneTime) continue;
132+
const createdAt = parseIsoDate(node.createdAt);
133+
if (!createdAt) continue;
134+
135+
const validUntil = createdAt + YEAR_MS;
136+
if (now > validUntil) continue;
137+
138+
if (!latestOneTime || createdAt > latestOneTime.createdAt) {
139+
latestOneTime = { createdAt, validUntil };
140+
}
141+
}
142+
143+
if (!latestOneTime) return null;
144+
145+
return {
146+
coverage: "one_time_within_year",
147+
created_at: new Date(latestOneTime.createdAt).toISOString(),
148+
valid_until: new Date(latestOneTime.validUntil).toISOString(),
149+
};
150+
}
151+
152+
async function findViewerSponsorship(token, sponsorLogin) {
153+
let after = null;
154+
let viewerLogin = null;
155+
156+
const query = `
157+
query($after: String, $maintainers: [String!]) {
158+
viewer {
159+
login
160+
sponsorshipsAsSponsor(first: 100, after: $after, activeOnly: false, maintainerLogins: $maintainers) {
161+
nodes {
162+
createdAt
163+
isActive
164+
tier { isOneTime }
165+
}
166+
pageInfo { hasNextPage endCursor }
167+
}
168+
}
169+
}
170+
`;
171+
172+
const nodes = [];
173+
do {
174+
const data = await githubGraphQL(token, query, {
175+
after,
176+
maintainers: [sponsorLogin],
177+
});
178+
viewerLogin = data.viewer.login;
179+
nodes.push(...(data.viewer.sponsorshipsAsSponsor.nodes ?? []));
180+
const pageInfo = data.viewer.sponsorshipsAsSponsor.pageInfo;
181+
after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
182+
} while (after);
183+
184+
const result = pickSponsorshipResult(nodes);
185+
if (!result) return null;
186+
return { match: viewerLogin, matchType: "user", ...result };
187+
}
188+
189+
async function listViewerOrgs(token) {
190+
let after = null;
191+
const orgs = [];
192+
const query = `
193+
query($after: String) {
194+
viewer {
195+
organizations(first: 100, after: $after) {
196+
nodes { login }
197+
pageInfo { hasNextPage endCursor }
198+
}
199+
}
200+
}
201+
`;
202+
203+
do {
204+
const data = await githubGraphQL(token, query, { after });
205+
const nodes = data.viewer.organizations.nodes ?? [];
206+
for (const node of nodes) {
207+
if (node?.login) orgs.push(node.login);
208+
}
209+
const pageInfo = data.viewer.organizations.pageInfo;
210+
after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
211+
} while (after);
212+
213+
return orgs;
214+
}
215+
216+
async function findOrgSponsorship(token, orgLogin, sponsorLogin) {
217+
let after = null;
218+
const query = `
219+
query($login: String!, $after: String, $maintainers: [String!]) {
220+
organization(login: $login) {
221+
sponsorshipsAsSponsor(first: 100, after: $after, activeOnly: false, maintainerLogins: $maintainers) {
222+
nodes {
223+
createdAt
224+
isActive
225+
tier { isOneTime }
226+
}
227+
pageInfo { hasNextPage endCursor }
228+
}
229+
}
230+
}
231+
`;
232+
233+
const nodes = [];
234+
do {
235+
const data = await githubGraphQL(token, query, {
236+
login: orgLogin,
237+
after,
238+
maintainers: [sponsorLogin],
239+
});
240+
const sponsorships = data.organization?.sponsorshipsAsSponsor;
241+
if (!sponsorships) {
242+
return null;
243+
}
244+
nodes.push(...(sponsorships.nodes ?? []));
245+
const pageInfo = sponsorships.pageInfo;
246+
after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
247+
} while (after);
248+
249+
const result = pickSponsorshipResult(nodes);
250+
if (!result) return null;
251+
return { match: orgLogin, matchType: "org", ...result };
252+
}
253+
254+
export async function onRequest(context) {
255+
context.passThroughOnException();
256+
257+
const url = new URL(context.request.url);
258+
const secure = url.protocol === "https:";
259+
const cookies = parseCookies(context.request.headers.get("Cookie"));
260+
261+
const returnPath = normalizeReturnPath(
262+
cookies[RETURN_COOKIE],
263+
DEFAULT_RETURN_PATH
264+
);
265+
const state = url.searchParams.get("state");
266+
const code = url.searchParams.get("code");
267+
const expectedState = cookies[STATE_COOKIE];
268+
269+
const clearHeaders = new Headers();
270+
clearHeaders.append("Set-Cookie", clearCookie(STATE_COOKIE, secure));
271+
clearHeaders.append("Set-Cookie", clearCookie(RETURN_COOKIE, secure));
272+
clearHeaders.set("Cache-Control", "no-store");
273+
274+
const redirectWith = (params) => {
275+
const location = buildReturnUrl(url.origin, returnPath, params);
276+
clearHeaders.set("Location", location);
277+
return new Response(null, { status: 302, headers: clearHeaders });
278+
};
279+
280+
if (!state || !expectedState || state !== expectedState) {
281+
return redirectWith({ osmf: "error", reason: "state" });
282+
}
283+
284+
if (!code) {
285+
return redirectWith({ osmf: "error", reason: "code" });
286+
}
287+
288+
try {
289+
const token = await exchangeToken({
290+
clientId: context.env.GITHUB_CLIENT_ID,
291+
clientSecret: context.env.GITHUB_CLIENT_SECRET,
292+
code,
293+
redirectUri: `${url.origin}/api/osmf/callback`,
294+
});
295+
296+
const viewerMatch = await findViewerSponsorship(token, SPONSOR_LOGIN);
297+
if (viewerMatch) {
298+
return redirectWith({
299+
osmf: "ok",
300+
match: viewerMatch.match,
301+
match_type: viewerMatch.matchType,
302+
coverage: viewerMatch.coverage,
303+
created_at: viewerMatch.created_at,
304+
valid_until: viewerMatch.valid_until,
305+
});
306+
}
307+
308+
const orgs = await listViewerOrgs(token);
309+
for (const org of orgs) {
310+
const orgMatch = await findOrgSponsorship(token, org, SPONSOR_LOGIN);
311+
if (orgMatch) {
312+
return redirectWith({
313+
osmf: "ok",
314+
match: orgMatch.match,
315+
match_type: orgMatch.matchType,
316+
coverage: orgMatch.coverage,
317+
created_at: orgMatch.created_at,
318+
valid_until: orgMatch.valid_until,
319+
});
320+
}
321+
}
322+
323+
return redirectWith({ osmf: "no", reason: "not_sponsor" });
324+
} catch (error) {
325+
return redirectWith({ osmf: "error", reason: "github" });
326+
}
327+
}

0 commit comments

Comments
 (0)