feat: implement file upload functionality and create file serving endpoint
All checks were successful
Build Docker Image / build (push) Successful in 1m20s

This commit is contained in:
2026-03-02 22:25:33 +01:00
parent bf963ee3fd
commit 74d051cbfe
4 changed files with 113 additions and 3 deletions

3
.gitignore vendored
View File

@@ -27,3 +27,6 @@ vite.config.ts.timestamp-*
# Script outputs # Script outputs
/scraped-data /scraped-data
# Uploads
/uploads

View File

@@ -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;

View File

@@ -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 }) => {
@@ -780,6 +789,20 @@
/> />
</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

View 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 });
}
};