Archived
150 lines
7.8 KiB
JavaScript
150 lines
7.8 KiB
JavaScript
// Gitea v1.27.3: API-class scopes, not repository restrictions. Worker only.
|
|
export const JUICE_ORIGINS = ['https://teahub.xyz'];
|
|
export const JUICE_SCOPES = ['read:repository', 'read:issue', 'write:issue'];
|
|
const idPattern = /^[0-9a-f]{32}$/;
|
|
const usernamePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
export function trustedJuice(message, sender) {
|
|
try {
|
|
return sender.origin === 'https://persimmons.app' && new URL(sender.url).origin === sender.origin &&
|
|
sender.frameId === 0 && message?.type === 'persimmons.juice.v1' &&
|
|
['create', 'rotate', 'revoke'].includes(message.action) &&
|
|
/^[a-z0-9][a-z0-9_.-]{0,99}$/.test(message.owner) &&
|
|
message.target && Object.keys(message.target).sort().join() === 'id,name' &&
|
|
Number.isSafeInteger(message.target.id) && message.target.id > 0 &&
|
|
/^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(message.target.name) &&
|
|
Object.keys(message).sort().join() === (message.action === 'create' ?
|
|
'action,owner,policy,source_id,target,type' : 'action,id,owner,target,type');
|
|
} catch { return false; }
|
|
}
|
|
export function checkedScopes(value) {
|
|
if (!Array.isArray(value) || !value.length || value.length > 3 ||
|
|
value.some(s => !JUICE_SCOPES.includes(s)) || new Set(value).size !== value.length ||
|
|
(value.includes('read:issue') && value.includes('write:issue'))) throw new Error();
|
|
return [...value].sort();
|
|
}
|
|
export async function juiceRequest(operation, owner, data, fetcher = fetch) {
|
|
const response = await fetcher('https://persimmons.app/api/juice/' + operation, {
|
|
method: 'POST', credentials: 'include', cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(20000),
|
|
headers: {'Content-Type': 'application/json', 'X-Vault-Owner': owner}, body: JSON.stringify(data)
|
|
});
|
|
if (!response.ok) throw new Error();
|
|
const result = await response.json();
|
|
if (result.owner !== owner) throw new Error();
|
|
return result;
|
|
}
|
|
export async function giteaToken(origin, account, name, scopes, fetcher = fetch) {
|
|
if (!JUICE_ORIGINS.includes(origin) || !usernamePattern.test(account.username) ||
|
|
!/^persimmons-juice-[0-9a-f]{32}$/.test(name)) throw new Error();
|
|
const bytes = new TextEncoder().encode(account.username + ':' + account.password);
|
|
let auth;
|
|
try {
|
|
auth = 'Basic ' + btoa(String.fromCharCode(...bytes));
|
|
const response = await fetcher(origin + '/api/v1/users/' + encodeURIComponent(account.username) + '/tokens' +
|
|
(scopes ? '' : '/' + name), {
|
|
method: scopes ? 'POST' : 'DELETE', credentials: 'omit', cache: 'no-store', redirect: 'error',
|
|
signal: AbortSignal.timeout(15000), headers: {'Content-Type': 'application/json', Authorization: auth},
|
|
...(scopes ? {body: JSON.stringify({name, scopes: checkedScopes(scopes)})} : {})
|
|
});
|
|
if (!scopes) {
|
|
if (response.status !== 204 && response.status !== 404) throw new Error();
|
|
return true;
|
|
}
|
|
if (response.status !== 201) throw new Error();
|
|
const token = await response.json();
|
|
if (!/^[0-9a-f]{40}$/.test(token.sha1) || token.name !== name ||
|
|
checkedScopes(token.scopes).join() !== checkedScopes(scopes).join()) throw new Error();
|
|
return token.sha1;
|
|
} finally { bytes.fill(0); auth = ''; }
|
|
}
|
|
|
|
// Dependencies keep crypto and Chrome state out of the adapter's test surface.
|
|
// No error object, provider response or credential can be returned to the page.
|
|
export async function runJuice(message, sender, deps) {
|
|
if (!trustedJuice(message, sender)) return null;
|
|
const {owner, target, action} = message;
|
|
const request = (op, data) => deps.request(op, owner, {target, ...data});
|
|
let newId, source, previous, prepared = false, attempted = false;
|
|
try {
|
|
await deps.live(owner);
|
|
if (action !== 'create') {
|
|
if (!idPattern.test(message.id)) throw new Error();
|
|
previous = (await request('list', {})).items.find(i => i.id === message.id);
|
|
if (!previous) throw new Error();
|
|
}
|
|
if (action === 'revoke') {
|
|
// Target removal does not depend on source availability or unlock state.
|
|
await request('remove', {id: previous.id});
|
|
let confirmed = false;
|
|
try {
|
|
await deps.unlocked(owner);
|
|
source = await deps.source(owner, previous.source_id);
|
|
if (source.origin !== previous.source_origin) throw new Error();
|
|
await deps.use(owner, source, async (account, check) => {
|
|
if (account.username !== previous.source_username) throw new Error();
|
|
await deps.live(owner); check();
|
|
await deps.provider(source.origin, account, 'persimmons-juice-' + previous.id);
|
|
confirmed = true;
|
|
});
|
|
} catch {}
|
|
await request('cleanup', {id: previous.id, provider_status: confirmed ? 'revoked' : 'unknown'});
|
|
return {ok: confirmed, id: previous.id, status: confirmed ? 'revoked' : 'partial-failure',
|
|
message: confirmed ? 'Revoked.' : 'ANDON: Target secret removed; provider revocation is unconfirmed. Unlock the source and retry Revoke.'};
|
|
}
|
|
await deps.unlocked(owner);
|
|
const sourceId = previous?.source_id || message.source_id;
|
|
if (!idPattern.test(sourceId)) throw new Error();
|
|
const scopes = checkedScopes(previous?.scopes || message.policy?.scopes);
|
|
if (action === 'create' && Object.keys(message.policy).join() !== 'scopes') throw new Error();
|
|
if (previous && previous.status !== 'active') throw new Error();
|
|
source = await deps.source(owner, sourceId);
|
|
if (!JUICE_ORIGINS.includes(source.origin)) return {ok: false, message: 'Juice adapter not available yet.'};
|
|
if (previous && previous.source_origin !== source.origin) throw new Error();
|
|
newId = deps.id();
|
|
return await deps.use(owner, source, async (account, check) => {
|
|
if (!usernamePattern.test(account.username) || (previous && account.username !== previous.source_username)) throw new Error();
|
|
let pat = '';
|
|
try {
|
|
await deps.live(owner); check();
|
|
await request('prepare', {id: newId, source_id: sourceId, source_username: account.username,
|
|
scopes, replaces: previous?.id || null});
|
|
prepared = true;
|
|
await deps.live(owner); check();
|
|
attempted = true;
|
|
pat = await deps.provider(source.origin, account, 'persimmons-juice-' + newId, scopes);
|
|
await deps.live(owner); check();
|
|
await request('write', {id: newId, pat});
|
|
pat = '';
|
|
} catch {
|
|
pat = '';
|
|
let confirmed = !attempted;
|
|
if (attempted) {
|
|
try { check(); await deps.provider(source.origin, account, 'persimmons-juice-' + newId); confirmed = true; } catch {}
|
|
}
|
|
let targetClean = !prepared;
|
|
if (prepared) {
|
|
try { await request('cleanup', {id: newId, provider_status: confirmed ? 'revoked' : 'unknown'}); targetClean = true; } catch {}
|
|
}
|
|
return {ok: false, id: newId, status: confirmed && targetClean ? 'revoked' : 'partial-failure',
|
|
message: confirmed && targetClean ? 'Juice failed; new token and target secret cleaned up.' :
|
|
'ANDON: Juice failed; cleanup is unconfirmed. Inspect the pending Juice card and retry Revoke.'};
|
|
} finally { pat = ''; }
|
|
if (previous) {
|
|
let confirmed = false;
|
|
try {
|
|
await request('remove', {id: previous.id});
|
|
await deps.live(owner); check();
|
|
await deps.provider(source.origin, account, 'persimmons-juice-' + previous.id);
|
|
confirmed = true;
|
|
await request('cleanup', {id: previous.id, provider_status: 'revoked'});
|
|
} catch {
|
|
return {ok: false, id: newId, status: 'partial-failure',
|
|
message: 'ANDON: Replacement stored; old token cleanup is ' + (confirmed ? 'not recorded' : 'unconfirmed') + '. Retry Revoke on the old card.'};
|
|
}
|
|
}
|
|
return {ok: true, id: newId, status: 'active'};
|
|
});
|
|
} catch {
|
|
return {ok: false, status: 'unavailable', message: 'Juice unavailable. Check connection, owner, source and unlocked vault.'};
|
|
}
|
|
}
|