@geminilight/mindos 0.5.37 → 0.5.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,33 +5,85 @@ import { signJwt } from '@/lib/jwt';
5
5
  const COOKIE_NAME = 'mindos-session';
6
6
  const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
7
7
 
8
+ // Allowed CORS origins for cross-origin auth (Capacitor, Electron remote).
9
+ // Localhost variants are always allowed; custom origins can be added here.
10
+ const ALLOWED_ORIGIN_PATTERNS = [
11
+ /^https?:\/\/localhost(:\d+)?$/,
12
+ /^https?:\/\/127\.0\.0\.1(:\d+)?$/,
13
+ /^https?:\/\/\[::1\](:\d+)?$/,
14
+ /^capacitor:\/\//,
15
+ /^file:\/\//,
16
+ ];
17
+
18
+ function isAllowedOrigin(origin: string): boolean {
19
+ return ALLOWED_ORIGIN_PATTERNS.some((p) => p.test(origin));
20
+ }
21
+
22
+ /** CORS headers — validate origin against allowlist */
23
+ function getAuthCors(req: NextRequest): Record<string, string> {
24
+ const origin = req.headers.get('origin');
25
+ // No origin header (same-origin browser request or non-browser client) → no CORS needed
26
+ if (!origin) return {};
27
+ // Validate origin
28
+ if (!isAllowedOrigin(origin)) return {};
29
+ return {
30
+ 'Access-Control-Allow-Origin': origin,
31
+ 'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS',
32
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
33
+ 'Access-Control-Allow-Credentials': 'true',
34
+ };
35
+ }
36
+
37
+ function withCors(res: NextResponse, req: NextRequest): NextResponse {
38
+ for (const [k, v] of Object.entries(getAuthCors(req))) res.headers.set(k, v);
39
+ return res;
40
+ }
41
+
42
+ // OPTIONS /api/auth — CORS preflight
43
+ export async function OPTIONS(req: NextRequest) {
44
+ const headers = getAuthCors(req);
45
+ if (Object.keys(headers).length === 0) {
46
+ return new Response(null, { status: 204 });
47
+ }
48
+ return new Response(null, { status: 204, headers });
49
+ }
50
+
8
51
  // POST /api/auth — validate password and set JWT session cookie
9
52
  export async function POST(req: NextRequest) {
10
53
  const webPassword = process.env.WEB_PASSWORD;
11
- if (!webPassword) return NextResponse.json({ ok: false }, { status: 401 });
54
+ if (!webPassword) return withCors(NextResponse.json({ ok: false }, { status: 401 }), req);
12
55
 
13
- const body = await req.json().catch(() => ({})) as { password?: string };
14
- if (body.password !== webPassword) return NextResponse.json({ ok: false }, { status: 401 });
56
+ const body = await req.json().catch(() => null);
57
+ if (!body || typeof body !== 'object') {
58
+ return withCors(NextResponse.json({ error: 'Invalid request body' }, { status: 400 }), req);
59
+ }
60
+ const { password } = body as { password?: string };
61
+ if (password !== webPassword) return withCors(NextResponse.json({ ok: false }, { status: 401 }), req);
15
62
 
16
63
  const token = await signJwt(
17
64
  { sub: 'user', exp: Math.floor(Date.now() / 1000) + COOKIE_MAX_AGE },
18
65
  webPassword,
19
66
  );
20
67
 
21
- // Only set Secure flag when served over HTTPS (x-forwarded-proto set by reverse proxy)
22
68
  const isHttps = req.headers.get('x-forwarded-proto') === 'https';
69
+ const origin = req.headers.get('origin');
70
+ // Cross-origin requests need SameSite=None + Secure (HTTPS required by spec).
71
+ // Same-origin or no-origin → use Lax (works over HTTP).
72
+ const isCrossOrigin = !!origin && isAllowedOrigin(origin);
73
+ const sameSite = isCrossOrigin && isHttps ? 'None' : 'Lax';
23
74
  const secure = isHttps ? '; Secure' : '';
75
+
24
76
  const res = NextResponse.json({ ok: true });
25
77
  res.headers.set(
26
78
  'Set-Cookie',
27
- `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE}; Path=/${secure}`,
79
+ `${COOKIE_NAME}=${token}; HttpOnly; SameSite=${sameSite}; Max-Age=${COOKIE_MAX_AGE}; Path=/${secure}`,
28
80
  );
29
- return res;
81
+ return withCors(res, req);
30
82
  }
31
83
 
32
84
  // DELETE /api/auth — clear session cookie (logout)
33
- export async function DELETE() {
85
+ export async function DELETE(req: NextRequest) {
34
86
  const res = NextResponse.json({ ok: true });
35
- res.headers.set('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`);
36
- return res;
87
+ res.headers.set('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/`);
88
+ return withCors(res, req);
37
89
  }
@@ -1,6 +1,51 @@
1
1
  export const dynamic = 'force-dynamic';
2
2
  import { NextResponse } from 'next/server';
3
+ import { readFileSync } from 'fs';
4
+ import { join, dirname } from 'path';
5
+
6
+ /**
7
+ * Read version from root package.json.
8
+ * Tries multiple paths to handle dev, production, and standalone builds.
9
+ */
10
+ function readVersion(): string {
11
+ // 1. Env var set by CLI (most reliable)
12
+ if (process.env.npm_package_version) return process.env.npm_package_version;
13
+
14
+ // 2. Try known relative paths from Next.js app directory
15
+ const candidates = [
16
+ join(process.cwd(), '..', 'package.json'), // dev: app/ → root
17
+ join(process.cwd(), 'package.json'), // if cwd is root
18
+ join(dirname(process.cwd()), 'package.json'), // standalone edge case
19
+ ];
20
+
21
+ for (const p of candidates) {
22
+ try {
23
+ const pkg = JSON.parse(readFileSync(p, 'utf-8'));
24
+ if (pkg.name === '@geminilight/mindos' && pkg.version) return pkg.version;
25
+ } catch { /* try next */ }
26
+ }
27
+ return '0.0.0';
28
+ }
29
+
30
+ const version = readVersion();
31
+
32
+ const CORS_HEADERS: Record<string, string> = {
33
+ 'Access-Control-Allow-Origin': '*',
34
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
35
+ 'Access-Control-Allow-Headers': 'Content-Type',
36
+ };
3
37
 
4
38
  export async function GET() {
5
- return NextResponse.json({ ok: true, service: 'mindos' });
39
+ const res = NextResponse.json({
40
+ ok: true,
41
+ service: 'mindos',
42
+ version,
43
+ authRequired: !!process.env.WEB_PASSWORD,
44
+ });
45
+ for (const [k, v] of Object.entries(CORS_HEADERS)) res.headers.set(k, v);
46
+ return res;
47
+ }
48
+
49
+ export async function OPTIONS() {
50
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
6
51
  }
@@ -303,7 +303,6 @@ body {
303
303
  .dark .modal-backdrop {
304
304
  background: rgba(0, 0, 0, 0.65);
305
305
  }
306
- }
307
306
 
308
307
  /* Micro type scale: text-2xs = 10px (between nothing and text-xs 12px) */
309
308
  @layer utilities {
package/app/app/page.tsx CHANGED
@@ -1,12 +1,56 @@
1
1
  import { redirect } from 'next/navigation';
2
2
  import { readSettings } from '@/lib/settings';
3
- import { getRecentlyModified, getFileContent } from '@/lib/fs';
3
+ import { getRecentlyModified, getFileContent, getFileTree } from '@/lib/fs';
4
4
  import { getAllRenderers } from '@/lib/renderers/registry';
5
5
  import '@/lib/renderers/index'; // registers all renderers
6
6
  import HomeContent from '@/components/HomeContent';
7
+ import type { FileNode } from '@/lib/core/types';
7
8
 
8
9
  export const dynamic = 'force-dynamic';
9
10
 
11
+ export interface SpaceInfo {
12
+ name: string;
13
+ path: string;
14
+ fileCount: number;
15
+ description: string; // first paragraph from README.md (after title)
16
+ }
17
+
18
+ function countFiles(node: FileNode): number {
19
+ if (node.type === 'file') return 1;
20
+ return (node.children ?? []).reduce((sum, c) => sum + countFiles(c), 0);
21
+ }
22
+
23
+ /** Extract the first non-empty paragraph after the title from a README.md */
24
+ function extractDescription(spacePath: string): string {
25
+ try {
26
+ const content = getFileContent(spacePath + 'README.md');
27
+ const lines = content.split('\n');
28
+ // Skip title line (# ...) and blank lines, return first non-empty non-heading line
29
+ for (const line of lines) {
30
+ const trimmed = line.trim();
31
+ if (!trimmed || trimmed.startsWith('#')) continue;
32
+ return trimmed;
33
+ }
34
+ } catch { /* README.md doesn't exist */ }
35
+ return '';
36
+ }
37
+
38
+ function getTopLevelDirs(): SpaceInfo[] {
39
+ try {
40
+ const tree = getFileTree();
41
+ return tree
42
+ .filter(n => n.type === 'directory' && !n.name.startsWith('.'))
43
+ .map(n => ({
44
+ name: n.name,
45
+ path: n.path + '/',
46
+ fileCount: countFiles(n),
47
+ description: extractDescription(n.path + '/'),
48
+ }));
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
10
54
  function getExistingFiles(paths: string[]): string[] {
11
55
  return paths.filter(p => {
12
56
  try { getFileContent(p); return true; } catch { return false; }
@@ -30,5 +74,7 @@ export default function HomePage() {
30
74
  .filter((p): p is string => !!p);
31
75
  const existingFiles = getExistingFiles(entryPaths);
32
76
 
33
- return <HomeContent recent={recent} existingFiles={existingFiles} />;
77
+ const spaces = getTopLevelDirs();
78
+
79
+ return <HomeContent recent={recent} existingFiles={existingFiles} spaces={spaces} />;
34
80
  }