Full document, spreadsheet, slideshow, and diagram tooling
1import { Hono } from 'hono';
2import { logger } from 'hono/logger';
3import { serveStatic } from '@hono/node-server/serve-static';
4import { readFileSync, existsSync } from 'node:fs';
5import { join } from 'node:path';
6import type { InstanceInfo } from './config.js';
7import {
8 generateSecret,
9 authMiddleware,
10 handleVerify,
11 handleLogout,
12} from './auth.js';
13
14const APP_NAMES = ['docs', 'sheets', 'forms', 'slides', 'diagrams', 'calendar'];
15
16export interface ServerConfig {
17 instanceInfo: InstanceInfo;
18 distPath: string;
19 cookieSecret?: Buffer;
20 version?: string;
21}
22
23export function createApp(config: ServerConfig): Hono {
24 const { instanceInfo, distPath, version = 'dev' } = config;
25 const secret = config.cookieSecret ?? generateSecret();
26 const startTime = Date.now();
27
28 const appHtml: Record<string, string> = {};
29 for (const name of APP_NAMES) {
30 const htmlPath = join(distPath, name, 'index.html');
31 if (existsSync(htmlPath)) {
32 appHtml[name] = readFileSync(htmlPath, 'utf-8');
33 }
34 }
35
36 const landingPath = join(distPath, 'index.html');
37 const landingHtml = existsSync(landingPath)
38 ? readFileSync(landingPath, 'utf-8')
39 : '<html><body>Atmosphere Office</body></html>';
40
41 const app = new Hono();
42
43 app.use('*', logger());
44
45 app.use('*', async (c, next) => {
46 const start = performance.now();
47 await next();
48 const ms = (performance.now() - start).toFixed(1);
49 c.res.headers.set('X-Response-Time', `${ms}ms`);
50 c.res.headers.set('X-Version', version);
51 });
52
53 app.get('/health', (c) =>
54 c.json({
55 status: 'ok',
56 version,
57 uptime: Math.floor((Date.now() - startTime) / 1000),
58 flavor: instanceInfo.flavor,
59 accessMode: instanceInfo.accessControl?.mode ?? 'open',
60 }),
61 );
62
63 app.get('/instance-info.json', (c) => {
64 c.header('Cache-Control', 'no-cache');
65 return c.json(instanceInfo);
66 });
67
68 app.post('/api/auth/verify', async (c) => {
69 try {
70 const body = await c.req.json();
71 return handleVerify(c, body.did, secret, instanceInfo);
72 } catch {
73 return c.json({ error: 'Invalid request body' }, 400);
74 }
75 });
76
77 app.post('/api/auth/logout', (c) => handleLogout(c));
78
79 const needsAuth =
80 instanceInfo.accessControl?.mode === 'allowlist' &&
81 instanceInfo.flavor !== 'self-hosted';
82
83 for (const name of APP_NAMES) {
84 const html = appHtml[name];
85 if (!html) continue;
86
87 const serve = (c: import('hono').Context) => c.html(html);
88
89 if (needsAuth) {
90 const mw = authMiddleware(secret, instanceInfo);
91 app.get(`/${name}`, mw, serve);
92 app.get(`/${name}/*`, mw, serve);
93 } else {
94 app.get(`/${name}`, serve);
95 app.get(`/${name}/*`, serve);
96 }
97 }
98
99 app.use(
100 '/assets/*',
101 async (c, next) => {
102 c.header('Cache-Control', 'public, max-age=31536000, immutable');
103 await next();
104 },
105 serveStatic({ root: distPath }),
106 );
107
108 app.use(
109 '/*',
110 serveStatic({
111 root: distPath,
112 onNotFound: () => {},
113 }),
114 );
115
116 app.get('*', (c) => c.html(landingHtml));
117
118 return app;
119}