diff --git a/apps/nas-samba/config.json b/apps/nas-samba/config.json index 884ddc0..79f6471 100644 --- a/apps/nas-samba/config.json +++ b/apps/nas-samba/config.json @@ -8,7 +8,7 @@ "dynamic_config": true, "no_gui": false, "tipi_version": 2, - "version": "1.0.1", + "version": "1.0.2", "categories": ["network", "utilities"], "description": "All-in-one NAS container with Samba SMB file sharing and CloudNAS web file manager. Browse files via web UI or connect via SMB at \\\\192.168.0.15\\data with username alexz. No database required — uses filesystem + SQLite.", "short_desc": "NAS with SMB + web file manager", diff --git a/apps/nas-samba/docker-compose.json b/apps/nas-samba/docker-compose.json index b0c6e32..7761be8 100644 --- a/apps/nas-samba/docker-compose.json +++ b/apps/nas-samba/docker-compose.json @@ -4,7 +4,7 @@ "services": [ { "name": "nas-samba", - "image": "git.alexzaw.dev/alexz/nas-samba:1.0.1", + "image": "git.alexzaw.dev/alexz/nas-samba:1.0.2", "isMain": true, "internalPort": 8080, "hostname": "nas.alexzaw.dev", diff --git a/apps/nas-samba/source/app/server.js b/apps/nas-samba/source/app/server.js index 8158253..c9473c7 100644 --- a/apps/nas-samba/source/app/server.js +++ b/apps/nas-samba/source/app/server.js @@ -29,6 +29,8 @@ const JWT_ALGORITHM = 'HS256'; const JWT_EXPIRATION_HOURS = 24; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'alexz'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.SMB_PASSWORD; +const TMP_DIR = '/tmp/tmpfiles'; +const TMP_EXPIRY_MS = 60 * 60 * 1000; // 1 hour // --------------------------------------------------------------------------- // SQLite setup @@ -74,6 +76,16 @@ function initDatabase() { details TEXT, created_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS tmp_files ( + id TEXT PRIMARY KEY, + token TEXT UNIQUE NOT NULL, + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ); `); } @@ -402,6 +414,22 @@ function getTypeBreakdown(dirPath) { return breakdown; } +// --------------------------------------------------------------------------- +// Tmp file cleanup +// --------------------------------------------------------------------------- +function cleanupTmpFiles() { + const now = new Date().toISOString(); + const expired = sqlDb.prepare('SELECT * FROM tmp_files WHERE expires_at <= ?').all(now); + for (const file of expired) { + const filePath = path.join(TMP_DIR, file.id); + try { fs.unlinkSync(filePath); } catch (_e) { /* already gone */ } + } + sqlDb.prepare('DELETE FROM tmp_files WHERE expires_at <= ?').run(now); + if (expired.length > 0) { + console.log(`Cleaned up ${expired.length} expired tmp files`); + } +} + // ========================================================================= // API ROUTES // ========================================================================= @@ -1445,6 +1473,99 @@ app.post('/api/download-zip', requireAuth, async (req, res) => { } }); +// --------------------------------------------------------------------------- +// Tmp File Routes (no auth, ephemeral storage) +// --------------------------------------------------------------------------- +app.post('/api/tmp/upload', upload.single('file'), (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ detail: 'No file provided' }); + } + + const fileId = crypto.randomUUID(); + const token = crypto.randomBytes(12).toString('base64url'); + const expiresAt = new Date(Date.now() + TMP_EXPIRY_MS); + const mimeType = req.file.mimetype || 'application/octet-stream'; + + fs.mkdirSync(TMP_DIR, { recursive: true }); + fs.writeFileSync(path.join(TMP_DIR, fileId), req.file.buffer); + + sqlDb.prepare( + 'INSERT INTO tmp_files (id, token, original_name, mime_type, size, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(fileId, token, req.file.originalname, mimeType, req.file.size, new Date().toISOString(), expiresAt.toISOString()); + + const baseUrl = `${req.protocol}://${req.get('host')}`; + return res.json({ + token, + url: `${baseUrl}/api/tmp/${token}`, + filename: req.file.originalname, + size: req.file.size, + expires_at: expiresAt.toISOString(), + }); + } catch (err) { + console.error('Tmp upload error:', err.message); + return res.status(500).json({ detail: 'Internal server error' }); + } +}); + +app.get('/api/tmp/:token', (req, res) => { + try { + const { token } = req.params; + const file = sqlDb.prepare('SELECT * FROM tmp_files WHERE token = ?').get(token); + if (!file) { + return res.status(404).json({ detail: 'File not found or expired' }); + } + + if (new Date() > new Date(file.expires_at)) { + const filePath = path.join(TMP_DIR, file.id); + try { fs.unlinkSync(filePath); } catch (_e) {} + sqlDb.prepare('DELETE FROM tmp_files WHERE id = ?').run(file.id); + return res.status(410).json({ detail: 'File has expired' }); + } + + const filePath = path.join(TMP_DIR, file.id); + if (!fs.existsSync(filePath)) { + sqlDb.prepare('DELETE FROM tmp_files WHERE id = ?').run(file.id); + return res.status(404).json({ detail: 'File not found' }); + } + + const stat = fs.statSync(filePath); + res.setHeader('Content-Type', file.mime_type); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.original_name)}"`); + res.setHeader('Content-Length', stat.size); + + fs.createReadStream(filePath).pipe(res); + } catch (err) { + console.error('Tmp download error:', err.message); + return res.status(500).json({ detail: 'Internal server error' }); + } +}); + +app.get('/api/tmp/:token/info', (req, res) => { + try { + const { token } = req.params; + const file = sqlDb.prepare('SELECT * FROM tmp_files WHERE token = ?').get(token); + if (!file) { + return res.status(404).json({ detail: 'File not found or expired' }); + } + + if (new Date() > new Date(file.expires_at)) { + return res.status(410).json({ detail: 'File has expired' }); + } + + return res.json({ + filename: file.original_name, + size: file.size, + mime_type: file.mime_type, + created_at: file.created_at, + expires_at: file.expires_at, + }); + } catch (err) { + console.error('Tmp info error:', err.message); + return res.status(500).json({ detail: 'Internal server error' }); + } +}); + // --------------------------------------------------------------------------- // Serve React build (SPA fallback) // --------------------------------------------------------------------------- @@ -1490,6 +1611,13 @@ function main() { // Seed admin seedAdmin(); + // Ensure tmp directory exists + fs.mkdirSync(TMP_DIR, { recursive: true }); + + // Cleanup expired tmp files on startup and every 5 minutes + cleanupTmpFiles(); + setInterval(cleanupTmpFiles, 5 * 60 * 1000); + // Start listening app.listen(PORT, () => { console.log(`CloudSync server running on port ${PORT}`);