feat: implement file upload functionality and create file serving endpoint
All checks were successful
Build Docker Image / build (push) Successful in 1m20s
All checks were successful
Build Docker Image / build (push) Successful in 1m20s
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -26,4 +26,7 @@ vite.config.ts.timestamp-*
|
|||||||
*.db
|
*.db
|
||||||
|
|
||||||
# Script outputs
|
# Script outputs
|
||||||
/scraped-data
|
/scraped-data
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
/uploads
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { character, devilFruit, arc, characterOverride } from '$lib/server/db/sc
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { writeFile } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const [charactersData, devilFruits, arcs, overrides] = await Promise.all([
|
const [charactersData, devilFruits, arcs, overrides] = await Promise.all([
|
||||||
@@ -101,8 +104,38 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const updates: Record<string, any> = {};
|
const updates: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Handle file upload
|
||||||
|
const pictureFile = formData.get('pictureFile') as File;
|
||||||
|
const hasUploadedPicture = !!pictureFile && pictureFile.size > 0;
|
||||||
|
if (hasUploadedPicture) {
|
||||||
|
try {
|
||||||
|
const uploadsDir = join(process.cwd(), 'uploads');
|
||||||
|
if (!existsSync(uploadsDir)) {
|
||||||
|
mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file extension
|
||||||
|
const extension = pictureFile.name.split('.').pop();
|
||||||
|
const filename = `${id}.${extension}`;
|
||||||
|
const filepath = join(uploadsDir, filename);
|
||||||
|
|
||||||
|
// Convert file to buffer and save
|
||||||
|
const buffer = Buffer.from(await pictureFile.arrayBuffer());
|
||||||
|
await writeFile(filepath, buffer);
|
||||||
|
|
||||||
|
// Update pictureUrl to point to the handler route
|
||||||
|
updates.pictureUrl = `/uploads/${filename}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File upload error:', error);
|
||||||
|
return fail(500, { error: 'Failed to upload file' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
formData.forEach((value, key) => {
|
formData.forEach((value, key) => {
|
||||||
if (key !== 'id') {
|
if (key !== 'id' && key !== 'pictureFile') {
|
||||||
|
if (hasUploadedPicture && key === 'pictureUrl') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Handle integers (age, bounty, height, devilFruitId, arcId)
|
// Handle integers (age, bounty, height, devilFruitId, arcId)
|
||||||
if (key === 'age' || key === 'bounty' || key === 'height' || key === 'devilFruitId' || key === 'arcId') {
|
if (key === 'age' || key === 'bounty' || key === 'height' || key === 'devilFruitId' || key === 'arcId') {
|
||||||
const strValue = value as string;
|
const strValue = value as string;
|
||||||
|
|||||||
@@ -30,6 +30,14 @@
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (target.files && target.files.length > 0) {
|
||||||
|
// Clear pictureUrl when a file is selected
|
||||||
|
editForm.pictureUrl = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getFandomUrl = (url: string | null | undefined) => {
|
const getFandomUrl = (url: string | null | undefined) => {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||||
@@ -516,6 +524,7 @@
|
|||||||
class="mt-6 space-y-4"
|
class="mt-6 space-y-4"
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/update"
|
action="?/update"
|
||||||
|
enctype="multipart/form-data"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
return async ({ result }) => {
|
return async ({ result }) => {
|
||||||
@@ -776,10 +785,24 @@
|
|||||||
name="pictureUrl"
|
name="pictureUrl"
|
||||||
bind:value={editForm.pictureUrl}
|
bind:value={editForm.pictureUrl}
|
||||||
placeholder={selectedChar?.pictureUrl || ''}
|
placeholder={selectedChar?.pictureUrl || ''}
|
||||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="char-picture-file" class="block text-sm font-medium text-gray-300 mb-2">Or Upload Picture</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="char-picture-file"
|
||||||
|
name="pictureFile"
|
||||||
|
accept="image/*"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-amber-600 file:text-white hover:file:bg-amber-700"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">File will be saved as {selectedChar?.id}.jpg/png/etc</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="char-url" class="block text-sm font-medium text-gray-300 mb-2">Fandom URL</label>
|
<label for="char-url" class="block text-sm font-medium text-gray-300 mb-2">Fandom URL</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
51
src/routes/uploads/[filename]/+server.ts
Normal file
51
src/routes/uploads/[filename]/+server.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const filename = params.filename as string;
|
||||||
|
|
||||||
|
if (!filename || typeof filename !== 'string' || filename.includes('..') || filename.includes('/')) {
|
||||||
|
return new Response('Invalid filename', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadsDir = join(process.cwd(), 'uploads');
|
||||||
|
const filepath = join(uploadsDir, filename);
|
||||||
|
|
||||||
|
if (!existsSync(filepath)) {
|
||||||
|
return new Response('File not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file is within uploads directory (prevent directory traversal)
|
||||||
|
if (!filepath.startsWith(uploadsDir)) {
|
||||||
|
return new Response('Access denied', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = await readFile(filepath);
|
||||||
|
|
||||||
|
// Determine content type based on file extension
|
||||||
|
const ext = (filename as string).split('.').pop()?.toLowerCase() || '';
|
||||||
|
const contentTypes: Record<string, string> = {
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'png': 'image/png',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'webp': 'image/webp',
|
||||||
|
'svg': 'image/svg+xml'
|
||||||
|
};
|
||||||
|
const contentType = contentTypes[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
return new Response(fileBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=86400'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File serving error:', error);
|
||||||
|
return new Response('Internal server error', { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user