Skip to content

Commit fe41abc

Browse files
feat: add support for creating project backup files and restoring from backup files
- Implemented POST /ycode/api/project/export to export projects as .ycode files, with optional password encryption. - Implemented POST /ycode/api/project/import to import .ycode files, handling both file uploads and optional decryption. - Introduced BackupRestoreDialog component for user interface interactions related to backup and restore functionalities. - Added ToastError class for improved error handling in toast notifications.
1 parent d5189dc commit fe41abc

6 files changed

Lines changed: 1152 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import {
3+
exportProject,
4+
packExport,
5+
getExportFilename,
6+
sanitizeProjectNameSlug,
7+
} from '@/lib/services/projectService';
8+
9+
export const dynamic = 'force-dynamic';
10+
export const revalidate = 0;
11+
12+
/**
13+
* POST /ycode/api/project/export
14+
*
15+
* Export the project as a compressed .ycode dump file.
16+
* Optionally encrypt with a password (JSON body: { "password": "..." }).
17+
*/
18+
export async function POST(request: NextRequest) {
19+
try {
20+
let password: string | undefined;
21+
let projectName: string | undefined;
22+
try {
23+
const body = await request.json();
24+
password = body.password || undefined;
25+
projectName = body.projectName || undefined;
26+
} catch {
27+
// No body or invalid JSON — export with defaults
28+
}
29+
30+
const result = await exportProject();
31+
32+
if (!result.success || !result.export) {
33+
return NextResponse.json(
34+
{ error: result.error || 'Export failed' },
35+
{ status: 500 }
36+
);
37+
}
38+
39+
if (projectName && projectName.trim()) {
40+
result.export.manifest.projectName = sanitizeProjectNameSlug(projectName.trim());
41+
}
42+
43+
const fileBuffer = packExport(result.export, password);
44+
const filename = getExportFilename(result.export.manifest);
45+
46+
return new NextResponse(new Uint8Array(fileBuffer), {
47+
status: 200,
48+
headers: {
49+
'Content-Type': 'application/octet-stream',
50+
'Content-Disposition': `attachment; filename="${filename}"`,
51+
'Cache-Control': 'no-store',
52+
},
53+
});
54+
} catch (error) {
55+
console.error('[POST /ycode/api/project/export] Error:', error);
56+
return NextResponse.json(
57+
{ error: error instanceof Error ? error.message : 'Export failed' },
58+
{ status: 500 }
59+
);
60+
}
61+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NextRequest } from 'next/server';
2+
import { noCache } from '@/lib/api-response';
3+
import { importProject, unpackImport } from '@/lib/services/projectService';
4+
import { clearAllCache } from '@/lib/services/cacheService';
5+
import { ToastError } from '@/lib/toast-error';
6+
7+
export const dynamic = 'force-dynamic';
8+
export const revalidate = 0;
9+
10+
/**
11+
* POST /ycode/api/project/import
12+
*
13+
* Import a project dump (.ycode file).
14+
* Accepts multipart form-data with:
15+
* - "file" (required): the .ycode file
16+
* - "password" (optional): decryption password if the file is encrypted
17+
*/
18+
export async function POST(request: NextRequest) {
19+
try {
20+
const formData = await request.formData();
21+
const file = formData.get('file');
22+
const password = formData.get('password');
23+
24+
if (!file || !(file instanceof Blob)) {
25+
return noCache({ error: 'No file provided. Upload a .ycode file as form-data with field name "file".' }, 400);
26+
}
27+
28+
const buffer = Buffer.from(await file.arrayBuffer());
29+
const passwordStr = typeof password === 'string' && password ? password : undefined;
30+
31+
let parsed;
32+
try {
33+
parsed = unpackImport(buffer, passwordStr);
34+
} catch (err) {
35+
if (err instanceof ToastError) {
36+
return noCache({ errorTitle: err.title, error: err.description }, 400);
37+
}
38+
return noCache({ error: err instanceof Error ? err.message : 'Invalid .ycode file.' }, 400);
39+
}
40+
41+
const result = await importProject(parsed.manifest, parsed.data, parsed.files);
42+
43+
if (!result.success) {
44+
return noCache({ error: result.error }, 500);
45+
}
46+
47+
await clearAllCache();
48+
49+
return noCache({
50+
success: true,
51+
message: 'Project imported successfully',
52+
stats: result.stats,
53+
});
54+
} catch (error) {
55+
console.error('[POST /ycode/api/project/import] Error:', error);
56+
return noCache(
57+
{ error: error instanceof Error ? error.message : 'Import failed' },
58+
500
59+
);
60+
}
61+
}

app/ycode/components/HeaderBar.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import PublishPopover from './PublishPopover';
3535
import { Label } from '@/components/ui/label';
3636
import Icon from '@/components/ui/icon';
3737
import { Separator } from '@/components/ui/separator';
38+
import { BackupRestoreDialog } from '@/components/project/BackupRestoreDialog';
3839

3940
interface HeaderBarProps {
4041
user: User | null;
@@ -124,6 +125,7 @@ export default function HeaderBar({
124125
});
125126
const [baseUrl, setBaseUrl] = useState<string>('');
126127
const [hasUpdate, setHasUpdate] = useState(false);
128+
const [showTransferDialog, setShowTransferDialog] = useState(false);
127129

128130
// Get current host after mount
129131
useEffect(() => {
@@ -353,6 +355,12 @@ export default function HeaderBar({
353355
Integrations
354356
</DropdownMenuItem>
355357

358+
<DropdownMenuItem
359+
onClick={() => setShowTransferDialog(true)}
360+
>
361+
Backup &amp; Restore
362+
</DropdownMenuItem>
363+
356364
<DropdownMenuSeparator />
357365

358366
<DropdownMenuSub>
@@ -597,6 +605,11 @@ export default function HeaderBar({
597605

598606
</div>
599607
</header>
608+
609+
<BackupRestoreDialog
610+
open={showTransferDialog}
611+
onOpenChange={setShowTransferDialog}
612+
/>
600613
</>
601614
);
602615
}

0 commit comments

Comments
 (0)