Consolidate Persimmons extension
This commit is contained in:
1 parent
69e19df172
commit
3887319730
1 file changed
+149
@@ -0,0 +1,149 @@
|
||||
// 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://teahub.xyz/tea/site/chat/persimmons/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.'};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user