Files
photo-showcase/server.js
2025-12-13 21:46:18 +01:00

292 lines
9.0 KiB
JavaScript

import express from 'express';
import multer from 'multer';
import cors from 'cors';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { createClient } from "@libsql/client";
import dotenv from 'dotenv';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 8080;
app.use(cors());
app.use(express.json());
// Directories
const DATA_DIR = path.join(__dirname, 'data');
const UPLOAD_DIR = path.join(__dirname, 'uploads');
// Ensure directories exist
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// Initialize LibSQL
const db = createClient({
url: "file:data/data.db"
});
// Initialize Schema
const initDB = async () => {
try {
await db.execute(`
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT,
description TEXT,
date TEXT,
location TEXT,
camera TEXT,
lens TEXT,
iso TEXT,
aperture TEXT,
shutter TEXT,
focalLength TEXT,
gps_lat REAL,
gps_lng REAL,
file_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
console.log("Database initialized");
} catch (err) {
console.error("Database initialization failed", err);
}
};
initDB();
// Multer Storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
// Use timestamp to ensure unique filenames
const ext = path.extname(file.originalname);
const name = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9]/g, '-');
cb(null, `${Date.now()}-${name}${ext}`);
}
});
const upload = multer({ storage });
// API Routes
// Get all photos
app.get('/api/photos', async (req, res) => {
try {
const result = await db.execute("SELECT * FROM photos ORDER BY date DESC");
// Map result rows to object format if needed, but LibSQL returns objects usually
// We might need to reconstruct the nested 'settings' or 'gps' objects for the frontend
const photos = result.rows.map(row => ({
id: row.id,
url: row.url,
title: row.title,
description: row.description,
date: row.date,
location: row.location,
camera: row.camera,
lens: row.lens,
settings: {
iso: row.iso,
aperture: row.aperture,
shutter: row.shutter,
focalLength: row.focalLength
},
gps: (row.gps_lat && row.gps_lng) ? { lat: row.gps_lat, lng: row.gps_lng } : null,
fileName: row.file_name
}));
res.json(photos);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to fetch photos" });
}
});
// Upload a photo
app.post('/api/photos', upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No image uploaded' });
}
const metadata = JSON.parse(req.body.metadata || '{}');
const fileName = req.file.filename;
const url = `/uploads/${fileName}`;
// Insert into DB
await db.execute({
sql: `INSERT INTO photos (
url, title, description, date, location, camera, lens,
iso, aperture, shutter, focalLength, gps_lat, gps_lng, file_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
url,
metadata.title || '',
metadata.description || '',
metadata.date || '',
metadata.location || '',
metadata.camera || '',
metadata.lens || '',
metadata.settings?.iso || '',
metadata.settings?.aperture || '',
metadata.settings?.shutter || '',
metadata.settings?.focalLength || '',
metadata.gps?.lat || null,
metadata.gps?.lng || null,
fileName
]
});
// We could fetch the inserted row, but for now just return success
// Simulating the returned object
const newPhoto = {
id: Date.now(), // approximation, real ID is in DB
url,
...metadata
};
res.status(201).json(newPhoto);
} catch (error) {
console.error("Upload error", error);
res.status(500).json({ error: 'Upload failed' });
}
});
// Delete a photo
app.delete('/api/photos/:id', async (req, res) => {
const id = parseInt(req.params.id);
try {
// Get filename first
const result = await db.execute({
sql: "SELECT file_name FROM photos WHERE id = ?",
args: [id]
});
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Photo not found' });
}
const fileName = result.rows[0].file_name;
// Remove from DB
await db.execute({
sql: "DELETE FROM photos WHERE id = ?",
args: [id]
});
// Remove file
if (fileName) {
const filePath = path.join(UPLOAD_DIR, fileName);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
res.json({ success: true });
} catch (err) {
console.error("Delete error", err);
res.status(500).json({ error: "Delete failed" });
}
});
// Serve Uploads
app.use('/uploads', express.static(UPLOAD_DIR));
// Favicon Storage
const faviconStorage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
// Preserve original file extension (e.g., .ico, .png)
const ext = path.extname(file.originalname) || '.png';
cb(null, `favicon${ext}`);
}
});
const uploadFavicon = multer({ storage: faviconStorage });
// Upload Favicon
app.post('/api/favicon', uploadFavicon.single('favicon'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
// Respond with the stored filename (including extension)
const storedName = req.file.filename;
res.json({ success: true, url: `/uploads/${storedName}` });
});
// Serve Favicon (Dynamic)
app.get('/api/favicon', (req, res) => {
// Determine the stored favicon file (any supported extension)
const possibleExts = ['.ico', '.png', '.svg', '.jpg', '.jpeg'];
let faviconPath = null;
for (const ext of possibleExts) {
const candidate = path.join(UPLOAD_DIR, `favicon${ext}`);
if (fs.existsSync(candidate)) {
faviconPath = candidate;
break;
}
}
if (faviconPath) {
// Set appropriate Content-Type based on extension
const mimeMap = {
'.ico': 'image/x-icon',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg'
};
const ext = path.extname(faviconPath).toLowerCase();
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream');
// Prevent caching so updates appear immediately
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.sendFile(faviconPath);
} else {
// Fallback to default (404)
res.status(404).send('Not found');
}
});
// Fallback for browsers requesting /favicon.ico directly
app.get('/favicon.ico', (req, res) => {
const possibleExts = ['.ico', '.png', '.svg', '.jpg', '.jpeg'];
for (const ext of possibleExts) {
const candidate = path.join(UPLOAD_DIR, `favicon${ext}`);
if (fs.existsSync(candidate)) {
const mimeMap = {
'.ico': 'image/x-icon',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg'
};
const mime = mimeMap[ext] || 'application/octet-stream';
res.setHeader('Content-Type', mime);
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
return res.sendFile(candidate);
}
}
res.status(404).send('Not found');
});
// Serve Frontend (Production)
const DIST_DIR = path.join(__dirname, 'dist');
if (fs.existsSync(DIST_DIR)) {
app.use(express.static(DIST_DIR));
// SPA Fallback
app.get(/.*/, (req, res) => {
res.sendFile(path.join(DIST_DIR, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});