mirror of
https://github.com/aljazceru/ditto.git
synced 2026-01-07 23:54:22 +01:00
api --> controllers/api
This commit is contained in:
103
src/controllers/api/accounts.ts
Normal file
103
src/controllers/api/accounts.ts
Normal 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,
|
||||
};
|
||||
35
src/controllers/api/apps.ts
Normal file
35
src/controllers/api/apps.ts
Normal 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 };
|
||||
6
src/controllers/api/fallback.ts
Normal file
6
src/controllers/api/fallback.ts
Normal 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 };
|
||||
45
src/controllers/api/instance.ts
Normal file
45
src/controllers/api/instance.ts
Normal 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;
|
||||
96
src/controllers/api/oauth.ts
Normal file
96
src/controllers/api/oauth.ts
Normal 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 };
|
||||
63
src/controllers/api/statuses.ts
Normal file
63
src/controllers/api/statuses.ts
Normal 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 };
|
||||
31
src/controllers/api/timelines.ts
Normal file
31
src/controllers/api/timelines.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user