api --> controllers/api

This commit is contained in:
Alex Gleason
2023-04-30 14:45:30 -05:00
parent 2554379cef
commit 610ce4444a
8 changed files with 17 additions and 21 deletions

View File

@@ -0,0 +1,103 @@
import { type AppController } from '@/app.ts';
import { nip05 } from '@/deps.ts';
import { getAuthor } from '@/client.ts';
import { toAccount } from '@/transmute.ts';
import { bech32ToPubkey } from '@/utils.ts';
import type { Event } from '@/event.ts';
const credentialsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const event = await getAuthor(pubkey);
if (event) {
return c.json(toAccount(event));
}
return c.json({ error: 'Could not find user.' }, 404);
};
const accountController: AppController = async (c) => {
const pubkey = c.req.param('pubkey');
const event = await getAuthor(pubkey);
if (event) {
return c.json(toAccount(event));
}
return c.json({ error: 'Could not find user.' }, 404);
};
const accountLookupController: AppController = async (c) => {
const acct = c.req.query('acct');
if (!acct) {
return c.json({ error: 'Missing `acct` query parameter.' }, 422);
}
const event = await lookupAccount(acct);
if (event) {
return c.json(toAccount(event));
}
return c.json({ error: 'Could not find user.' }, 404);
};
const accountSearchController: AppController = async (c) => {
const q = c.req.query('q');
if (!q) {
return c.json({ error: 'Missing `q` query parameter.' }, 422);
}
const event = await lookupAccount(decodeURIComponent(q));
if (event) {
return c.json([toAccount(event)]);
}
return c.json([]);
};
const relationshipsController: AppController = (c) => {
const ids = c.req.queries('id[]');
if (!ids) {
return c.json({ error: 'Missing `id[]` query parameters.' }, 422);
}
const result = ids.map((id) => ({
id,
following: false,
showing_reblogs: false,
notifying: false,
followed_by: false,
blocking: false,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: false,
domain_blocking: false,
endorsed: false,
}));
return c.json(result);
};
/** Resolve a bech32 or NIP-05 identifier to an account. */
async function lookupAccount(value: string): Promise<Event<0> | undefined> {
console.log(`Looking up ${value}`);
const pubkey = bech32ToPubkey(value) || (await nip05.queryProfile(value))?.pubkey;
if (pubkey) {
return getAuthor(pubkey);
}
}
export {
accountController,
accountLookupController,
accountSearchController,
credentialsController,
relationshipsController,
};

View File

@@ -0,0 +1,35 @@
import type { Context } from '@/deps.ts';
/**
* Apps are unnecessary cruft in Mastodon API, but necessary to make clients work.
* So when clients try to "create" an app, pretend they did and return a hardcoded app.
*/
const FAKE_APP = {
id: '1',
name: 'Ditto',
website: null,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
client_id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', // he cry
client_secret: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', // 😱 😱 😱
vapid_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
};
async function createAppController(c: Context) {
// TODO: Handle both formData and json. 422 on parsing error.
try {
const { redirect_uris } = await c.req.json();
return c.json({
...FAKE_APP,
redirect_uri: redirect_uris || FAKE_APP.redirect_uri,
});
} catch (_e) {
return c.json(FAKE_APP);
}
}
function appCredentialsController(c: Context) {
return c.json(FAKE_APP);
}
export { appCredentialsController, createAppController };

View File

@@ -0,0 +1,6 @@
import type { Context } from '@/deps.ts';
const emptyArrayController = (c: Context) => c.json([]);
const emptyObjectController = (c: Context) => c.json({});
export { emptyArrayController, emptyObjectController };

View File

@@ -0,0 +1,45 @@
import { LOCAL_DOMAIN, POST_CHAR_LIMIT } from '@/config.ts';
import type { Context } from '@/deps.ts';
function instanceController(c: Context) {
const { host } = new URL(LOCAL_DOMAIN);
return c.json({
uri: host,
title: 'Ditto',
description: 'An efficient and flexible social media server.',
short_description: 'An efficient and flexible social media server.',
registrations: false,
max_toot_chars: POST_CHAR_LIMIT,
configuration: {
media_attachments: {
image_size_limit: 100000000,
video_size_limit: 100000000,
},
polls: {
max_characters_per_option: 0,
max_expiration: 0,
max_options: 0,
min_expiration: 0,
},
statuses: {
max_characters: POST_CHAR_LIMIT,
max_media_attachments: 20,
},
},
languages: ['en'],
stats: {
domain_count: 0,
status_count: 0,
user_count: 0,
},
urls: {
streaming_api: `wss://${host}`,
},
version: '0.0.0 (compatible; Ditto 0.0.1)',
rules: [],
});
}
export default instanceController;

View File

@@ -0,0 +1,96 @@
import { z } from '@/deps.ts';
import { AppController } from '@/app.ts';
import { parseBody } from '@/utils.ts';
const passwordGrantSchema = z.object({
grant_type: z.literal('password'),
password: z.string(),
});
const codeGrantSchema = z.object({
grant_type: z.literal('authorization_code'),
code: z.string(),
});
const createTokenSchema = z.discriminatedUnion('grant_type', [
passwordGrantSchema,
codeGrantSchema,
]);
const createTokenController: AppController = async (c) => {
const body = await parseBody(c.req.raw);
const data = createTokenSchema.parse(body);
switch (data.grant_type) {
case 'password':
return c.json({
access_token: data.password,
token_type: 'Bearer',
scope: 'read write follow push',
created_at: Math.floor(new Date().getTime() / 1000),
});
case 'authorization_code':
return c.json({
access_token: data.code,
token_type: 'Bearer',
scope: 'read write follow push',
created_at: Math.floor(new Date().getTime() / 1000),
});
}
};
/** Display the OAuth form. */
const oauthController: AppController = (c) => {
const encodedUri = c.req.query('redirect_uri');
if (!encodedUri) {
return c.text('Missing `redirect_uri` query param.', 422);
}
const redirectUri = decodeURIComponent(encodedUri);
// Poor man's XSS check.
// TODO: Render form with JSX.
try {
new URL(redirectUri);
} catch (_e) {
return c.text('Invalid `redirect_uri`.', 422);
}
c.res.headers.set('content-security-policy', 'default-src \'self\'');
// TODO: Login with `window.nostr` (NIP-07).
return c.html(`<!DOCTYPE html>
<html>
<head>
<title>Log in with Ditto</title>
</head>
<body>
<form action="/oauth/authorize" method="post">
<input type="text" placeholder="npub1... or nsec1..." name="nostr_id" autocomplete="off">
<input type="hidden" name="redirect_uri" id="redirect_uri" value="${redirectUri}" autocomplete="off">
<button type="submit">Authorize</button>
</form>
</body>
</html>
`);
};
const oauthAuthorizeController: AppController = async (c) => {
const formData = await c.req.formData();
const nostrId = formData.get('nostr_id');
const redirectUri = formData.get('redirect_uri');
if (nostrId && redirectUri) {
const url = new URL(redirectUri.toString());
const q = new URLSearchParams();
q.set('code', nostrId.toString());
url.search = q.toString();
return c.redirect(url.toString());
}
return c.text('Missing `redirect_uri` or `nostr_id`.', 422);
};
export { createTokenController, oauthAuthorizeController, oauthController };

View File

@@ -0,0 +1,63 @@
import { type AppContext, AppController } from '@/app.ts';
import { getAncestors, getDescendants, getEvent } from '@/client.ts';
import { validator, z } from '@/deps.ts';
import { type Event } from '@/event.ts';
import publish from '@/publisher.ts';
import { signEvent } from '@/sign.ts';
import { toStatus } from '@/transmute.ts';
const createStatusSchema = z.object({
status: z.string(),
});
const statusController: AppController = async (c) => {
const id = c.req.param('id');
const event = await getEvent(id, 1);
if (event) {
return c.json(await toStatus(event as Event<1>));
}
return c.json({ error: 'Event not found.' }, 404);
};
const createStatusController = validator('json', async (value, c: AppContext) => {
const result = createStatusSchema.safeParse(value);
if (result.success) {
const { data } = result;
const event = await signEvent<1>({
kind: 1,
content: data.status,
tags: [],
created_at: Math.floor(new Date().getTime() / 1000),
}, c);
publish(event);
return c.json(await toStatus(event));
} else {
return c.json({ error: 'Bad request' }, 400);
}
});
const contextController: AppController = async (c) => {
const id = c.req.param('id');
const event = await getEvent(id, 1);
if (event) {
const ancestorEvents = await getAncestors(event);
const descendantEvents = await getDescendants(event.id);
return c.json({
ancestors: (await Promise.all((ancestorEvents).map(toStatus))).filter(Boolean),
descendants: (await Promise.all((descendantEvents).map(toStatus))).filter(Boolean),
});
}
return c.json({ error: 'Event not found.' }, 404);
};
export { contextController, createStatusController, statusController };

View File

@@ -0,0 +1,31 @@
import { type AppController } from '@/app.ts';
import { getFeed, getFollows } from '@/client.ts';
import { LOCAL_DOMAIN } from '@/config.ts';
import { z } from '@/deps.ts';
import { toStatus } from '@/transmute.ts';
const homeController: AppController = async (c) => {
const since = paramSchema.parse(c.req.query('since'));
const until = paramSchema.parse(c.req.query('until'));
const pubkey = c.get('pubkey')!;
const follows = await getFollows(pubkey);
if (!follows) {
return c.json([]);
}
const events = await getFeed(follows, { since, until });
const statuses = (await Promise.all(events.map(toStatus))).filter(Boolean);
const next = `${LOCAL_DOMAIN}/api/v1/timelines/home?until=${events[events.length - 1].created_at}`;
const prev = `${LOCAL_DOMAIN}/api/v1/timelines/home?since=${events[0].created_at}`;
return c.json(statuses, 200, {
link: `<${next}>; rel="next", <${prev}>; rel="prev"`,
});
};
const paramSchema = z.coerce.number().optional().catch(undefined);
export { homeController };