65 | dangerouslySetInnerHTML={{ __html: bioHtml }}
66 | />
67 |
68 | );
69 | })}
70 |
75 | ,
76 | );
77 | });
78 |
79 | export default homePage;
80 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { Hono } from "hono";
2 | import { trimTrailingSlash } from "hono/trailing-slash";
3 | import accounts from "./accounts";
4 | import auth from "./auth";
5 | import emojis from "./emojis";
6 | import federation from "./federation";
7 | import home from "./home";
8 | import login from "./login";
9 | import logout from "./logout";
10 | import profile from "./profile";
11 | import setup from "./setup";
12 | import tags from "./tags";
13 |
14 | const page = new Hono();
15 |
16 | page.use(trimTrailingSlash());
17 | page.route("/", home);
18 | page.route("/:handle{@[^/]+}", profile);
19 | page.route("/login", login);
20 | page.route("/logout", logout);
21 | page.route("/setup", setup);
22 | page.route("/auth", auth);
23 | page.route("/accounts", accounts);
24 | page.route("/emojis", emojis);
25 | page.route("/federation", federation);
26 | page.route("/tags", tags);
27 |
28 | export default page;
29 |
--------------------------------------------------------------------------------
/src/pages/logout.tsx:
--------------------------------------------------------------------------------
1 | import { Hono } from "hono";
2 | import { deleteCookie } from "hono/cookie";
3 |
4 | const logout = new Hono();
5 |
6 | logout.post("/", async (c) => {
7 | await deleteCookie(c, "login");
8 | return c.redirect("/");
9 | });
10 |
11 | export default logout;
12 |
--------------------------------------------------------------------------------
/src/pages/oauth/authorization_code.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from "../../components/Layout";
2 | import type { Application } from "../../schema";
3 |
4 | interface AuthorizationCodePageProps {
5 | application: Application;
6 | code: string;
7 | }
8 |
9 | export function AuthorizationCodePage(props: AuthorizationCodePageProps) {
10 | return (
11 |
12 |
13 | Authorization Code
14 | Here is your authorization code.
15 |
16 | {props.code}
17 |
18 | Copy this code and paste it into {props.application.name}.
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/previewcard.ts:
--------------------------------------------------------------------------------
1 | import ogs from "open-graph-scraper";
2 |
3 | export interface PreviewCard {
4 | url: string;
5 | title: string;
6 | description: string | null;
7 | image: {
8 | url: string;
9 | type: string | null;
10 | width: number | null;
11 | height: number | null;
12 | } | null;
13 | }
14 |
15 | export async function fetchPreviewCard(
16 | url: string | URL,
17 | ): Promise
{
18 | let response: Awaited>;
19 | try {
20 | response = await ogs({ url: url.toString() });
21 | } catch (_) {
22 | return null;
23 | }
24 | const { error, result } = response;
25 | if (error || !result.success || result.ogTitle == null) return null;
26 | return {
27 | url: result.ogUrl ?? url.toString(),
28 | title: result.ogTitle,
29 | description: result.ogDescription ?? "",
30 | image:
31 | result.ogImage == null || result.ogImage.length < 1
32 | ? null
33 | : {
34 | url: result.ogImage[0].url,
35 | type: result.ogImage[0].type ?? null,
36 | width:
37 | result.ogImage[0].width == null
38 | ? null
39 | : Number.parseInt(result.ogImage[0].width as unknown as string),
40 | height:
41 | result.ogImage[0].height == null
42 | ? null
43 | : Number.parseInt(
44 | result.ogImage[0].height as unknown as string,
45 | ),
46 | },
47 | };
48 | }
49 |
--------------------------------------------------------------------------------
/src/public/favicon-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/src/public/favicon-white.png
--------------------------------------------------------------------------------
/src/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/src/public/favicon.png
--------------------------------------------------------------------------------
/src/public/hollo.css:
--------------------------------------------------------------------------------
1 | .logout-form {
2 | display: inline-flex;
3 | margin: 0;
4 | }
5 |
6 | .logout-btn {
7 | margin: 0;
8 | padding: var(--pico-nav-link-spacing-vertical)
9 | var(--pico-nav-link-spacing-horizontal);
10 | display: inline-block;
11 | width: auto !important;
12 | }
13 |
14 | footer {
15 | margin-block: calc(var(--pico-spacing) * 2);
16 | padding-top: calc(var(--pico-spacing) * 2);
17 | border-top: var(--pico-border-width) solid var(--pico-muted-border-color);
18 | font-size: 0.75rem;
19 | text-align: center;
20 | }
21 |
22 | @media (prefers-color-scheme: dark) {
23 | .shiki,
24 | .shiki span {
25 | color: var(--shiki-dark) !important;
26 | background-color: var(--shiki-dark-bg) !important;
27 | font-style: var(--shiki-dark-font-style) !important;
28 | font-weight: var(--shiki-dark-font-weight) !important;
29 | text-decoration: var(--shiki-dark-text-decoration) !important;
30 | }
31 | }
32 |
33 | /* cSpell: ignore shiki */
34 |
--------------------------------------------------------------------------------
/src/sentry.ts:
--------------------------------------------------------------------------------
1 | import { getLogger } from "@logtape/logtape";
2 | import { getGlobalScope, setCurrentClient } from "@sentry/core";
3 | import { type NodeClient, init, initOpenTelemetry } from "@sentry/node";
4 |
5 | const logger = getLogger(["hollo", "sentry"]);
6 |
7 | export function configureSentry(dsn?: string): NodeClient | undefined {
8 | if (dsn == null || dsn.trim() === "") {
9 | logger.debug("SENTRY_DSN is not provided. Sentry will not be initialized.");
10 | return;
11 | }
12 |
13 | const client = init({
14 | dsn,
15 | tracesSampleRate: 1.0,
16 | });
17 | if (client == null) {
18 | logger.error("Failed to initialize Sentry.");
19 | return;
20 | }
21 | getGlobalScope().setClient(client);
22 | setCurrentClient(client);
23 | logger.debug("Sentry initialized.");
24 |
25 | initOpenTelemetry(client);
26 | return client;
27 | }
28 |
--------------------------------------------------------------------------------
/src/uuid.ts:
--------------------------------------------------------------------------------
1 | import { uuidv7 as generateUuidV7 } from "uuidv7-js";
2 | import { z } from "zod";
3 |
4 | export type Uuid = ReturnType;
5 |
6 | export function uuidv7(timestamp?: number): Uuid {
7 | return generateUuidV7(timestamp) as Uuid;
8 | }
9 |
10 | const UUID_REGEXP = /^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
11 |
12 | export function isUuid(value: string): value is Uuid {
13 | return UUID_REGEXP.exec(value) != null;
14 | }
15 |
16 | export const uuid = z.custom(
17 | (v: unknown) => typeof v === "string" && isUuid(v),
18 | "expected a UUID",
19 | );
20 |
--------------------------------------------------------------------------------
/tests/fixtures/files/emoji.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/tests/fixtures/files/emoji.png
--------------------------------------------------------------------------------
/tests/helpers.ts:
--------------------------------------------------------------------------------
1 | import { readFile } from "node:fs/promises";
2 | import { join } from "node:path";
3 | import { after, before } from "node:test";
4 | import { sql } from "drizzle-orm";
5 |
6 | import db from "../src/db";
7 | import { drive } from "../src/storage";
8 |
9 | const fixtureFiles = join(import.meta.dirname, "fixtures", "files");
10 |
11 | export async function getFixtureFile(
12 | name: string,
13 | type: string,
14 | ): Promise {
15 | const filePath = join(fixtureFiles, name);
16 | const data = await readFile(filePath);
17 |
18 | return new File([data], name, {
19 | type,
20 | });
21 | }
22 |
23 | export async function cleanDatabase() {
24 | const schema = "public";
25 | const tables = await db.execute>(
26 | sql`SELECT table_name FROM information_schema.tables WHERE table_schema = ${schema} AND table_type = 'BASE TABLE';`,
27 | );
28 |
29 | const tableExpression = tables
30 | .map((table) => {
31 | return [`"${schema}"`, `"${table.table_name}"`].join(".");
32 | })
33 | .join(", ");
34 |
35 | await db.execute(
36 | sql.raw(`TRUNCATE TABLE ${tableExpression} RESTART IDENTITY CASCADE`),
37 | );
38 | }
39 |
40 | before(async () => {
41 | await cleanDatabase();
42 | });
43 |
44 | // Automatically close the database and remove test file uploads
45 | // Without this the tests hang due to the database
46 | after(async () => {
47 | await db.$client.end({ timeout: 5 });
48 |
49 | const disk = drive.fake();
50 | await disk.deleteAll();
51 | });
52 |
--------------------------------------------------------------------------------
/tests/helpers/web.ts:
--------------------------------------------------------------------------------
1 | import { serializeSigned } from "hono/utils/cookie";
2 | import { SECRET_KEY } from "../../src/env";
3 |
4 | export async function getLoginCookie() {
5 | // Same logic as in src/pages/login.tsx
6 | return serializeSigned("login", new Date().toISOString(), SECRET_KEY!, {
7 | path: "/",
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["ESNext", "DOM"],
4 | "target": "ESNext",
5 | "module": "ESNext",
6 | "moduleDetection": "force",
7 | "jsx": "react-jsx",
8 | "jsxImportSource": "hono/jsx",
9 | "allowJs": true,
10 | "esModuleInterop": true,
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "verbatimModuleSyntax": true,
14 | "noEmit": true,
15 | "strict": true,
16 | "skipLibCheck": true,
17 | "noFallthroughCasesInSwitch": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noPropertyAccessFromIndexSignature": false
21 | },
22 | "exclude": ["node_modules", "docs"]
23 | }
24 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { readFile } from "node:fs/promises";
2 | import { join } from "node:path";
3 | import { parse } from "dotenv";
4 | import { defineConfig } from "vitest/config";
5 |
6 | const env = parse(
7 | await readFile(join(process.cwd(), ".env.test"), { encoding: "utf-8" }),
8 | );
9 |
10 | export default defineConfig(() => ({
11 | test: {
12 | env: env,
13 | reporters: process.env.GITHUB_ACTIONS
14 | ? ["default", "github-actions"]
15 | : ["default"],
16 | fileParallelism: false,
17 | expect: {
18 | requireAssertions: true,
19 | },
20 | coverage: {
21 | include: ["src/**/*.ts", "src/**/*.tsx"],
22 | // These files don't really make sense to try to collect coverage on as
23 | // they're setup files:
24 | exclude: [
25 | "src/env.ts",
26 | "src/logging.ts",
27 | "src/sentry.ts",
28 | // database setup:
29 | "src/db.ts",
30 | "src/schema.ts",
31 | // storage setup:
32 | "src/storage.ts",
33 | ],
34 | },
35 | },
36 | }));
37 |
--------------------------------------------------------------------------------