nca-ai-cms-astro-plugin 1.0.16 → 1.0.18

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nca-ai-cms-astro-plugin",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -0,0 +1,35 @@
1
+ import type { APIRoute } from 'astro';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import { jsonError } from '../_utils';
5
+
6
+ function getDbPath(): string {
7
+ const envPath = process.env.ASTRO_DATABASE_FILE;
8
+ if (envPath) {
9
+ const resolved = path.resolve(process.cwd(), envPath);
10
+ if (!resolved.startsWith(process.cwd())) {
11
+ throw new Error('Invalid database path');
12
+ }
13
+ return resolved;
14
+ }
15
+ return path.join(process.cwd(), '.astro', 'content.db');
16
+ }
17
+
18
+ export const GET: APIRoute = async () => {
19
+ try {
20
+ const dbPath = getDbPath();
21
+ const dbBuffer = await fs.readFile(dbPath);
22
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
23
+
24
+ return new Response(dbBuffer, {
25
+ status: 200,
26
+ headers: {
27
+ 'Content-Type': 'application/x-sqlite3',
28
+ 'Content-Disposition': `attachment; filename="content-${timestamp}.db"`,
29
+ 'Content-Length': String(dbBuffer.length),
30
+ },
31
+ });
32
+ } catch {
33
+ return jsonError('Database file not found', 404);
34
+ }
35
+ };
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as fs from 'fs/promises';
3
+
4
+ const mockClose = vi.fn();
5
+ const mockReconnect = vi.fn();
6
+
7
+ vi.mock('astro:db', () => ({
8
+ db: {
9
+ $client: {
10
+ close: mockClose,
11
+ reconnect: mockReconnect,
12
+ },
13
+ },
14
+ }));
15
+
16
+ vi.mock('fs/promises', () => ({
17
+ writeFile: vi.fn().mockResolvedValue(undefined),
18
+ copyFile: vi.fn().mockResolvedValue(undefined),
19
+ }));
20
+
21
+ // SQLite file header
22
+ const SQLITE_HEADER = Buffer.from('SQLite format 3\0');
23
+
24
+ function createSqliteBuffer(size = 4096): Buffer {
25
+ const buf = Buffer.alloc(size);
26
+ SQLITE_HEADER.copy(buf);
27
+ return buf;
28
+ }
29
+
30
+ function createFormDataRequest(file: Buffer, fieldName = 'database'): Request {
31
+ const formData = new FormData();
32
+ formData.append(fieldName, new Blob([file]), 'test.db');
33
+ return new Request('http://localhost/api/db/upload', {
34
+ method: 'POST',
35
+ body: formData,
36
+ });
37
+ }
38
+
39
+ function createOctetStreamRequest(file: Buffer): Request {
40
+ return new Request('http://localhost/api/db/upload', {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/octet-stream' },
43
+ body: file,
44
+ });
45
+ }
46
+
47
+ describe('DB Upload API', () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ process.env.ASTRO_DATABASE_FILE = '.astro/content.db';
51
+ });
52
+
53
+ it('accepts a valid SQLite file via multipart/form-data', async () => {
54
+ const { POST } = await import('./upload.js');
55
+ const request = createFormDataRequest(createSqliteBuffer());
56
+
57
+ const response = await POST({ request } as any);
58
+ const data = await response.json();
59
+
60
+ expect(response.status).toBe(200);
61
+ expect(data.success).toBe(true);
62
+ expect(data.size).toBeGreaterThan(0);
63
+ });
64
+
65
+ it('accepts a valid SQLite file via application/octet-stream', async () => {
66
+ const { POST } = await import('./upload.js');
67
+ const request = createOctetStreamRequest(createSqliteBuffer());
68
+
69
+ const response = await POST({ request } as any);
70
+ const data = await response.json();
71
+
72
+ expect(response.status).toBe(200);
73
+ expect(data.success).toBe(true);
74
+ });
75
+
76
+ it('rejects non-SQLite files', async () => {
77
+ const { POST } = await import('./upload.js');
78
+ const badFile = Buffer.from('not a sqlite file');
79
+ const request = createFormDataRequest(badFile);
80
+
81
+ const response = await POST({ request } as any);
82
+ const data = await response.json();
83
+
84
+ expect(response.status).toBe(400);
85
+ expect(data.error).toContain('not a SQLite database');
86
+ });
87
+
88
+ it('rejects missing database field', async () => {
89
+ const { POST } = await import('./upload.js');
90
+ const formData = new FormData();
91
+ formData.append('wrongfield', new Blob([createSqliteBuffer()]), 'test.db');
92
+ const request = new Request('http://localhost/api/db/upload', {
93
+ method: 'POST',
94
+ body: formData,
95
+ });
96
+
97
+ const response = await POST({ request } as any);
98
+ const data = await response.json();
99
+
100
+ expect(response.status).toBe(400);
101
+ expect(data.error).toContain('No database file');
102
+ });
103
+
104
+ it('rejects invalid content type', async () => {
105
+ const { POST } = await import('./upload.js');
106
+ const request = new Request('http://localhost/api/db/upload', {
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'text/plain' },
109
+ body: 'hello',
110
+ });
111
+
112
+ const response = await POST({ request } as any);
113
+ const data = await response.json();
114
+
115
+ expect(response.status).toBe(400);
116
+ expect(data.error).toContain('Invalid content type');
117
+ });
118
+
119
+ it('creates a backup before writing', async () => {
120
+ const { POST } = await import('./upload.js');
121
+ const request = createFormDataRequest(createSqliteBuffer());
122
+
123
+ await POST({ request } as any);
124
+
125
+ expect(fs.copyFile).toHaveBeenCalled();
126
+ });
127
+
128
+ it('reconnects the DB client after successful upload', async () => {
129
+ const { POST } = await import('./upload.js');
130
+ const request = createFormDataRequest(createSqliteBuffer());
131
+
132
+ await POST({ request } as any);
133
+
134
+ expect(mockClose).toHaveBeenCalledOnce();
135
+ expect(mockReconnect).toHaveBeenCalledOnce();
136
+ });
137
+
138
+ it('rejects oversized file via multipart/form-data', async () => {
139
+ const { POST } = await import('./upload.js');
140
+ const oversized = createSqliteBuffer(50 * 1024 * 1024 + 1);
141
+ const request = createFormDataRequest(oversized);
142
+
143
+ const response = await POST({ request } as any);
144
+ const data = await response.json();
145
+
146
+ expect(response.status).toBe(413);
147
+ expect(data.error).toContain('too large');
148
+ });
149
+
150
+ it('rejects oversized file via application/octet-stream', async () => {
151
+ const { POST } = await import('./upload.js');
152
+ const oversized = createSqliteBuffer(50 * 1024 * 1024 + 1);
153
+ const request = createOctetStreamRequest(oversized);
154
+
155
+ const response = await POST({ request } as any);
156
+ const data = await response.json();
157
+
158
+ expect(response.status).toBe(413);
159
+ expect(data.error).toContain('too large');
160
+ });
161
+
162
+ it('still succeeds if reconnect is not available', async () => {
163
+ const { POST } = await import('./upload.js');
164
+ mockClose.mockImplementation(() => { throw new Error('not available'); });
165
+
166
+ const request = createFormDataRequest(createSqliteBuffer());
167
+ const response = await POST({ request } as any);
168
+ const data = await response.json();
169
+
170
+ expect(response.status).toBe(200);
171
+ expect(data.success).toBe(true);
172
+ });
173
+ });
@@ -0,0 +1,87 @@
1
+ import type { APIRoute } from 'astro';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import { jsonResponse, jsonError } from '../_utils';
5
+ // @ts-ignore - resolved by Astro build pipeline
6
+ import { db } from 'astro:db';
7
+
8
+ const MAX_DB_SIZE = 50 * 1024 * 1024; // 50 MB
9
+
10
+ function getDbPath(): string {
11
+ const envPath = process.env.ASTRO_DATABASE_FILE;
12
+ if (envPath) {
13
+ const resolved = path.resolve(process.cwd(), envPath);
14
+ if (!resolved.startsWith(process.cwd())) {
15
+ throw new Error('Invalid database path');
16
+ }
17
+ return resolved;
18
+ }
19
+ return path.join(process.cwd(), '.astro', 'content.db');
20
+ }
21
+
22
+ export const POST: APIRoute = async ({ request }) => {
23
+ try {
24
+ const dbPath = getDbPath();
25
+ const contentType = request.headers.get('content-type') || '';
26
+
27
+ let dbBuffer: Buffer;
28
+
29
+ if (contentType.includes('multipart/form-data')) {
30
+ const formData = await request.formData();
31
+ const file = formData.get('database') as File | null;
32
+
33
+ if (!file) {
34
+ return jsonError('No database file provided. Use field name "database".', 400);
35
+ }
36
+
37
+ if (file.size > MAX_DB_SIZE) {
38
+ return jsonError('File too large. Maximum 50 MB.', 413);
39
+ }
40
+
41
+ const arrayBuffer = await file.arrayBuffer();
42
+ dbBuffer = Buffer.from(arrayBuffer);
43
+ } else if (contentType.includes('application/octet-stream')) {
44
+ const arrayBuffer = await request.arrayBuffer();
45
+ dbBuffer = Buffer.from(arrayBuffer);
46
+
47
+ if (dbBuffer.length > MAX_DB_SIZE) {
48
+ return jsonError('File too large. Maximum 50 MB.', 413);
49
+ }
50
+ } else {
51
+ return jsonError('Invalid content type. Use multipart/form-data or application/octet-stream.', 400);
52
+ }
53
+
54
+ // SQLite validation
55
+ const header = dbBuffer.subarray(0, 16).toString('utf8');
56
+ if (!header.startsWith('SQLite format 3')) {
57
+ return jsonError('Invalid file: not a SQLite database.', 400);
58
+ }
59
+
60
+ // Backup current DB before overwriting
61
+ try {
62
+ await fs.copyFile(dbPath, `${dbPath}.backup`);
63
+ } catch {
64
+ // No existing DB to backup
65
+ }
66
+
67
+ await fs.writeFile(dbPath, dbBuffer);
68
+
69
+ // Reconnect DB client to pick up the new file
70
+ try {
71
+ const client = (db as any).$client;
72
+ if (client?.close && client?.reconnect) {
73
+ await client.close();
74
+ await client.reconnect();
75
+ }
76
+ } catch {
77
+ // Reconnect not available — manual restart needed
78
+ }
79
+
80
+ return jsonResponse({
81
+ success: true,
82
+ size: dbBuffer.length,
83
+ });
84
+ } catch {
85
+ return jsonError('Database upload failed', 500);
86
+ }
87
+ };
@@ -289,6 +289,50 @@ export function SettingsTab() {
289
289
  ))}
290
290
  </div>
291
291
 
292
+ {/* Database Management — only on Website tab */}
293
+ {activeSubTab === 'website' && (
294
+ <div style={{ ...styles.plannerForm, flexDirection: 'row', alignItems: 'center' }}>
295
+ <span style={{ ...styles.label, marginRight: 'auto' }}>Datenbank-Verwaltung</span>
296
+ <a
297
+ href="/api/db/download"
298
+ style={{ ...styles.editButton, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}
299
+ >
300
+ ↓ DB herunterladen
301
+ </a>
302
+ <label style={{ ...styles.editButton, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.4rem', margin: 0 }}>
303
+ ↑ DB hochladen
304
+ <input
305
+ type="file"
306
+ accept=".db,.sqlite,.sqlite3"
307
+ style={styles.srOnly}
308
+ onChange={async (e) => {
309
+ const file = e.target.files?.[0];
310
+ if (!file) return;
311
+ if (!confirm(`Datenbank "${file.name}" hochladen? Die aktuelle DB wird ueberschrieben.`)) {
312
+ e.target.value = '';
313
+ return;
314
+ }
315
+ const formData = new FormData();
316
+ formData.append('database', file);
317
+ try {
318
+ const res = await fetch('/api/db/upload', { method: 'POST', body: formData });
319
+ const data = await res.json();
320
+ if (res.ok) {
321
+ alert('Datenbank hochgeladen. Seite wird neu geladen.');
322
+ window.location.reload();
323
+ } else {
324
+ alert('Fehler: ' + (data.error || 'Upload fehlgeschlagen'));
325
+ }
326
+ } catch {
327
+ alert('Upload fehlgeschlagen');
328
+ }
329
+ e.target.value = '';
330
+ }}
331
+ />
332
+ </label>
333
+ </div>
334
+ )}
335
+
292
336
  {loading && (
293
337
  <div style={styles.loadingBox}>Einstellungen werden geladen...</div>
294
338
  )}
package/src/index.ts CHANGED
@@ -181,6 +181,18 @@ export default function ncaAiCms(
181
181
  prerender: false,
182
182
  });
183
183
 
184
+ // Inject DB management routes (auth-protected via middleware)
185
+ injectRoute({
186
+ pattern: '/api/db/download',
187
+ entrypoint: 'nca-ai-cms-astro-plugin/api/db/download.ts',
188
+ prerender: false,
189
+ });
190
+ injectRoute({
191
+ pattern: '/api/db/upload',
192
+ entrypoint: 'nca-ai-cms-astro-plugin/api/db/upload.ts',
193
+ prerender: false,
194
+ });
195
+
184
196
  // Inject auth routes
185
197
  injectRoute({
186
198
  pattern: '/api/auth/login',
package/update.md CHANGED
@@ -1,3 +1,32 @@
1
+ # v1.0.18
2
+
3
+ ## Fix: DB upload reconnects client after file replacement
4
+ - After writing the new SQLite file, the libsql client is closed and reconnected via `db.$client.close()` / `db.$client.reconnect()`
5
+ - Close and reconnect calls are now properly awaited to prevent race conditions with async DB operations
6
+ - Changes from uploaded database are immediately visible without node restart
7
+ - Graceful fallback: if reconnect is not available, upload still succeeds (manual restart needed)
8
+
9
+ ## Tests: DB upload endpoint coverage
10
+ - 10 tests covering upload endpoint — validation, backup, reconnect behavior, size limits
11
+ - Added size limit rejection tests for both `multipart/form-data` and `application/octet-stream` content types
12
+ - Tests verify 50 MB max file size is enforced with proper 413 status response
13
+
14
+ ---
15
+
16
+ # v1.0.17
17
+
18
+ ## Feature: Database download/upload via Editor UI
19
+ - New GET `/api/db/download` endpoint — exports SQLite database as file download with timestamp
20
+ - New POST `/api/db/upload` endpoint — imports SQLite database with validation and backup
21
+ - Download/Upload buttons in Editor → Einstellungen → Website tab
22
+ - Path traversal protection: database path validated against project root
23
+ - File size limit: 50 MB max upload
24
+ - SQLite header validation on upload (rejects non-SQLite files)
25
+ - Automatic backup of current DB before overwrite (`content.db.backup`)
26
+ - Both routes auth-protected via existing middleware
27
+
28
+ ---
29
+
1
30
  # v1.0.16
2
31
 
3
32
  ## Feature: Pages content type with flat URL structure