ditto/src/app.ts
2024-12-27 13:18:22 -06:00

426 lines
18 KiB
TypeScript

import { type Context, Env as HonoEnv, Handler, Hono, Input as HonoInput, MiddlewareHandler } from '@hono/hono';
import { cors } from '@hono/hono/cors';
import { serveStatic } from '@hono/hono/deno';
import { logger } from '@hono/hono/logger';
import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
import { Kysely } from 'kysely';
import '@/startup.ts';
import { DittoTables } from '@/db/DittoTables.ts';
import { Time } from '@/utils/time.ts';
import {
accountController,
accountLookupController,
accountSearchController,
accountStatusesController,
blockController,
createAccountController,
familiarFollowersController,
favouritesController,
followController,
followersController,
followingController,
muteController,
relationshipsController,
unblockController,
unfollowController,
unmuteController,
updateCredentialsController,
verifyCredentialsController,
} from '@/controllers/api/accounts.ts';
import {
adminAccountsController,
adminActionController,
adminApproveController,
adminRejectController,
} from '@/controllers/api/admin.ts';
import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
import { blocksController } from '@/controllers/api/blocks.ts';
import { bookmarksController } from '@/controllers/api/bookmarks.ts';
import { captchaController, captchaVerifyController } from '@/controllers/api/captcha.ts';
import {
adminRelaysController,
adminSetRelaysController,
deleteZapSplitsController,
getZapSplitsController,
nameRequestController,
nameRequestsController,
statusZapSplitsController,
updateInstanceController,
updateZapSplitsController,
} from '@/controllers/api/ditto.ts';
import { emptyArrayController, notImplementedController } from '@/controllers/api/fallback.ts';
import {
instanceDescriptionController,
instanceV1Controller,
instanceV2Controller,
} from '@/controllers/api/instance.ts';
import { markersController, updateMarkersController } from '@/controllers/api/markers.ts';
import { mediaController, updateMediaController } from '@/controllers/api/media.ts';
import { mutesController } from '@/controllers/api/mutes.ts';
import { notificationController, notificationsController } from '@/controllers/api/notifications.ts';
import {
createTokenController,
oauthAuthorizeController,
oauthController,
revokeTokenController,
} from '@/controllers/api/oauth.ts';
import {
configController,
frontendConfigController,
pleromaAdminDeleteStatusController,
pleromaAdminSuggestController,
pleromaAdminTagController,
pleromaAdminUnsuggestController,
pleromaAdminUntagController,
updateConfigController,
} from '@/controllers/api/pleroma.ts';
import { preferencesController } from '@/controllers/api/preferences.ts';
import { getSubscriptionController, pushSubscribeController } from '@/controllers/api/push.ts';
import { deleteReactionController, reactionController, reactionsController } from '@/controllers/api/reactions.ts';
import { relayController } from '@/controllers/nostr/relay.ts';
import {
adminReportController,
adminReportReopenController,
adminReportResolveController,
adminReportsController,
reportController,
} from '@/controllers/api/reports.ts';
import { searchController } from '@/controllers/api/search.ts';
import {
bookmarkController,
contextController,
createStatusController,
deleteStatusController,
favouriteController,
favouritedByController,
pinController,
quotesController,
rebloggedByController,
reblogStatusController,
statusController,
unbookmarkController,
unpinController,
unreblogStatusController,
zapController,
zappedByController,
} from '@/controllers/api/statuses.ts';
import { streamingController } from '@/controllers/api/streaming.ts';
import { suggestionsV1Controller, suggestionsV2Controller } from '@/controllers/api/suggestions.ts';
import {
hashtagTimelineController,
homeTimelineController,
publicTimelineController,
suggestedTimelineController,
} from '@/controllers/api/timelines.ts';
import {
trendingLinksController,
trendingStatusesController,
trendingTagsController,
} from '@/controllers/api/trends.ts';
import { translateController } from '@/controllers/api/translate.ts';
import { errorHandler } from '@/controllers/error.ts';
import { frontendController } from '@/controllers/frontend.ts';
import { metricsController } from '@/controllers/metrics.ts';
import { indexController } from '@/controllers/site.ts';
import { manifestController } from '@/controllers/manifest.ts';
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
import { nostrController } from '@/controllers/well-known/nostr.ts';
import { DittoTranslator } from '@/interfaces/DittoTranslator.ts';
import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
import { cspMiddleware } from '@/middleware/cspMiddleware.ts';
import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts';
import { paginationMiddleware } from '@/middleware/paginationMiddleware.ts';
import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts';
import { requireSigner } from '@/middleware/requireSigner.ts';
import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
import { storeMiddleware } from '@/middleware/storeMiddleware.ts';
import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
export interface AppEnv extends HonoEnv {
Variables: {
/** Signer to get the logged-in user's pubkey, relays, and to sign events, or `undefined` if the user isn't logged in. */
signer?: NostrSigner;
/** Uploader for the user to upload files. */
uploader?: NUploader;
/** NIP-98 signed event proving the pubkey is owned by the user. */
proof?: NostrEvent;
/** Kysely instance for the database. */
kysely: Kysely<DittoTables>;
/** Storage for the user, might filter out unwanted content. */
store: NStore;
/** Normalized pagination params. */
pagination: { since?: number; until?: number; limit: number };
/** Normalized list pagination params. */
listPagination: { offset: number; limit: number };
/** Translation service. */
translator?: DittoTranslator;
};
}
type AppContext = Context<AppEnv>;
type AppMiddleware = MiddlewareHandler<AppEnv>;
type AppController = Handler<AppEnv, any, HonoInput, Response | Promise<Response>>;
const app = new Hono<AppEnv>({ strict: false });
const debug = Debug('ditto:http');
/** User-provided files in the gitignored `public/` directory. */
const publicFiles = serveStatic({ root: './public/' });
/** Static files provided by the Ditto repo, checked into git. */
const staticFiles = serveStatic({ root: './static/' });
app.use('*', rateLimitMiddleware(300, Time.minutes(5)));
app.use('/api/*', metricsMiddleware, paginationMiddleware, logger(debug));
app.use('/.well-known/*', metricsMiddleware, logger(debug));
app.use('/users/*', metricsMiddleware, logger(debug));
app.use('/nodeinfo/*', metricsMiddleware, logger(debug));
app.use('/oauth/*', metricsMiddleware, logger(debug));
app.get('/api/v1/streaming', metricsMiddleware, streamingController);
app.get('/relay', metricsMiddleware, relayController);
app.use(
'*',
cspMiddleware(),
cors({ origin: '*', exposeHeaders: ['link'] }),
signerMiddleware,
uploaderMiddleware,
auth98Middleware(),
storeMiddleware,
);
app.get('/metrics', metricsController);
app.get('/.well-known/nodeinfo', nodeInfoController);
app.get('/.well-known/nostr.json', nostrController);
app.get('/nodeinfo/:version', nodeInfoSchemaController);
app.get('/manifest.webmanifest', manifestController);
app.get('/api/v1/instance', instanceV1Controller);
app.get('/api/v2/instance', instanceV2Controller);
app.get('/api/v1/instance/extended_description', instanceDescriptionController);
app.get('/api/v1/apps/verify_credentials', appCredentialsController);
app.post('/api/v1/apps', createAppController);
app.post('/oauth/token', createTokenController);
app.post('/oauth/revoke', revokeTokenController);
app.post('/oauth/authorize', oauthAuthorizeController);
app.get('/oauth/authorize', oauthController);
app.post('/api/v1/accounts', requireProof({ pow: 20 }), createAccountController);
app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController);
app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController);
app.get('/api/v1/accounts/search', accountSearchController);
app.get('/api/v1/accounts/lookup', accountLookupController);
app.get('/api/v1/accounts/relationships', requireSigner, relationshipsController);
app.get('/api/v1/accounts/familiar_followers', requireSigner, familiarFollowersController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', requireSigner, blockController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unblock', requireSigner, unblockController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/mute', requireSigner, muteController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unmute', requireSigner, unmuteController);
app.post(
'/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow',
rateLimitMiddleware(2, Time.seconds(1)),
requireSigner,
followController,
);
app.post(
'/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unfollow',
rateLimitMiddleware(2, Time.seconds(1)),
requireSigner,
unfollowController,
);
app.get(
'/api/v1/accounts/:pubkey{[0-9a-f]{64}}/followers',
rateLimitMiddleware(8, Time.seconds(30)),
followersController,
);
app.get(
'/api/v1/accounts/:pubkey{[0-9a-f]{64}}/following',
rateLimitMiddleware(8, Time.seconds(30)),
followingController,
);
app.get(
'/api/v1/accounts/:pubkey{[0-9a-f]{64}}/statuses',
rateLimitMiddleware(12, Time.seconds(30)),
accountStatusesController,
);
app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}', accountController);
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/favourited_by', favouritedByController);
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/reblogged_by', rebloggedByController);
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/context', contextController);
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}', statusController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', requireSigner, favouriteController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requireSigner, bookmarkController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requireSigner, unbookmarkController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requireSigner, pinController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requireSigner, unpinController);
app.post(
'/api/v1/statuses/:id{[0-9a-f]{64}}/translate',
requireSigner,
rateLimitMiddleware(15, Time.minutes(1)),
translatorMiddleware,
translateController,
);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', requireSigner, reblogStatusController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', requireSigner, unreblogStatusController);
app.post('/api/v1/statuses', requireSigner, createStatusController);
app.delete('/api/v1/statuses/:id{[0-9a-f]{64}}', requireSigner, deleteStatusController);
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/quotes', quotesController);
app.post('/api/v1/media', mediaController);
app.put(
'/api/v1/media/:id{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}',
updateMediaController,
);
app.post('/api/v2/media', mediaController);
app.get('/api/v1/timelines/home', rateLimitMiddleware(8, Time.seconds(30)), requireSigner, homeTimelineController);
app.get('/api/v1/timelines/public', rateLimitMiddleware(8, Time.seconds(30)), publicTimelineController);
app.get('/api/v1/timelines/tag/:hashtag', rateLimitMiddleware(8, Time.seconds(30)), hashtagTimelineController);
app.get('/api/v1/timelines/suggested', rateLimitMiddleware(8, Time.seconds(30)), suggestedTimelineController);
app.get('/api/v1/preferences', preferencesController);
app.get('/api/v1/search', searchController);
app.get('/api/v2/search', searchController);
app.get('/api/pleroma/frontend_configurations', frontendConfigController);
app.get('/api/v1/trends/statuses', rateLimitMiddleware(8, Time.seconds(30)), trendingStatusesController);
app.get('/api/v1/trends/links', trendingLinksController);
app.get('/api/v1/trends/tags', trendingTagsController);
app.get('/api/v1/trends', trendingTagsController);
app.get('/api/v1/suggestions', suggestionsV1Controller);
app.get('/api/v2/suggestions', suggestionsV2Controller);
app.get('/api/v1/notifications', rateLimitMiddleware(8, Time.seconds(30)), requireSigner, notificationsController);
app.get('/api/v1/notifications/:id', requireSigner, notificationController);
app.get('/api/v1/favourites', requireSigner, favouritesController);
app.get('/api/v1/bookmarks', requireSigner, bookmarksController);
app.get('/api/v1/blocks', requireSigner, blocksController);
app.get('/api/v1/mutes', requireSigner, mutesController);
app.get('/api/v1/markers', requireProof(), markersController);
app.post('/api/v1/markers', requireProof(), updateMarkersController);
app.get('/api/v1/push/subscription', requireSigner, getSubscriptionController);
app.post('/api/v1/push/subscription', requireProof(), pushSubscribeController);
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions', reactionsController);
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactionsController);
app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController);
app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, deleteReactionController);
app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController);
app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController);
app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController);
app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysController);
app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController);
app.put('/api/v1/admin/ditto/instance', requireRole('admin'), updateInstanceController);
app.post('/api/v1/ditto/names', requireSigner, nameRequestController);
app.get('/api/v1/ditto/names', requireSigner, nameRequestsController);
app.get('/api/v1/ditto/captcha', rateLimitMiddleware(3, Time.minutes(1)), captchaController);
app.post(
'/api/v1/ditto/captcha/:id/verify',
rateLimitMiddleware(8, Time.minutes(1)),
requireProof(),
captchaVerifyController,
);
app.get('/api/v1/ditto/zap_splits', getZapSplitsController);
app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController);
app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController);
app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController);
app.post('/api/v1/ditto/zap', requireSigner, zapController);
app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController);
app.post('/api/v1/reports', requireSigner, reportController);
app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController);
app.get('/api/v1/admin/reports/:id{[0-9a-f]{64}}', requireSigner, requireRole('admin'), adminReportController);
app.post(
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/resolve',
requireSigner,
requireRole('admin'),
adminReportResolveController,
);
app.post(
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen',
requireSigner,
requireRole('admin'),
adminReportReopenController,
);
app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController);
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminActionController);
app.post(
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve',
requireSigner,
requireRole('admin'),
adminApproveController,
);
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject', requireSigner, requireRole('admin'), adminRejectController);
app.put('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminTagController);
app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController);
app.patch('/api/v1/pleroma/admin/users/suggest', requireRole('admin'), pleromaAdminSuggestController);
app.patch('/api/v1/pleroma/admin/users/unsuggest', requireRole('admin'), pleromaAdminUnsuggestController);
// Not (yet) implemented.
app.get('/api/v1/custom_emojis', emptyArrayController);
app.get('/api/v1/filters', emptyArrayController);
app.get('/api/v1/domain_blocks', emptyArrayController);
app.get('/api/v1/conversations', emptyArrayController);
app.get('/api/v1/lists', emptyArrayController);
app.use('/api/*', notImplementedController);
app.use('/.well-known/*', publicFiles, notImplementedController);
app.use('/nodeinfo/*', notImplementedController);
app.use('/oauth/*', notImplementedController);
// Known frontend routes
app.get('/:acct{@.*}', frontendController);
app.get('/:acct{@.*}/*', frontendController);
app.get('/:bech32{^[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}$}', frontendController);
app.get('/users/*', frontendController);
app.get('/tags/*', frontendController);
app.get('/statuses/*', frontendController);
app.get('/notice/*', frontendController);
app.get('/timeline/*', frontendController);
// Known static file routes
app.get('/favicon.ico', publicFiles, staticFiles);
app.get('/images/*', publicFiles, staticFiles);
app.get('/instance/*', publicFiles);
app.get('/packs/*', publicFiles);
app.get('/sw.js', publicFiles);
// Site index
app.get('/', frontendController, indexController);
// Fallback
app.get('*', publicFiles, staticFiles, frontendController);
app.onError(errorHandler);
export default app;
export type { AppContext, AppController, AppMiddleware };