nas-samba v1.0.2: add tmpfile ephemeral upload/download endpoints
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
"dynamic_config": true,
|
"dynamic_config": true,
|
||||||
"no_gui": false,
|
"no_gui": false,
|
||||||
"tipi_version": 2,
|
"tipi_version": 2,
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"categories": ["network", "utilities"],
|
"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.",
|
"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",
|
"short_desc": "NAS with SMB + web file manager",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"services": [
|
"services": [
|
||||||
{
|
{
|
||||||
"name": "nas-samba",
|
"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,
|
"isMain": true,
|
||||||
"internalPort": 8080,
|
"internalPort": 8080,
|
||||||
"hostname": "nas.alexzaw.dev",
|
"hostname": "nas.alexzaw.dev",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const JWT_ALGORITHM = 'HS256';
|
|||||||
const JWT_EXPIRATION_HOURS = 24;
|
const JWT_EXPIRATION_HOURS = 24;
|
||||||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'alexz';
|
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'alexz';
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.SMB_PASSWORD;
|
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
|
// SQLite setup
|
||||||
@@ -74,6 +76,16 @@ function initDatabase() {
|
|||||||
details TEXT,
|
details TEXT,
|
||||||
created_at TEXT NOT NULL
|
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;
|
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
|
// 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)
|
// Serve React build (SPA fallback)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1490,6 +1611,13 @@ function main() {
|
|||||||
// Seed admin
|
// Seed admin
|
||||||
seedAdmin();
|
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
|
// Start listening
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`CloudSync server running on port ${PORT}`);
|
console.log(`CloudSync server running on port ${PORT}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user