Initial commit
This commit is contained in:
255
server/index.js
Normal file
255
server/index.js
Normal file
@@ -0,0 +1,255 @@
|
||||
import express from 'express';
|
||||
import { existsSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3001);
|
||||
const MAX_ENTRIES = Number(process.env.DRIVEBOARD_MAX_ENTRIES ?? 12000);
|
||||
const LOCAL_OWNER = 'Local file system';
|
||||
|
||||
const imageExtensions = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif']);
|
||||
const videoExtensions = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v']);
|
||||
const pdfExtensions = new Set(['pdf']);
|
||||
const archiveExtensions = new Set(['zip', 'rar', '7z', 'tar', 'gz']);
|
||||
const spreadsheetExtensions = new Set(['xls', 'xlsx', 'csv', 'tsv', 'ods']);
|
||||
const slideExtensions = new Set(['ppt', 'pptx', 'key']);
|
||||
const documentExtensions = new Set(['doc', 'docx', 'txt', 'md', 'rtf', 'odt']);
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const distPath = path.join(projectRoot, 'dist');
|
||||
|
||||
function compareDirents(left, right) {
|
||||
if (left.isDirectory() !== right.isDirectory()) {
|
||||
return left.isDirectory() ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function createItemId(kind, pathSegments) {
|
||||
return `${kind}:${pathSegments.join('/')}`;
|
||||
}
|
||||
|
||||
function inferFileType(fileName) {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
|
||||
|
||||
if (imageExtensions.has(extension)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (videoExtensions.has(extension)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (pdfExtensions.has(extension)) {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
if (archiveExtensions.has(extension)) {
|
||||
return 'zip';
|
||||
}
|
||||
|
||||
if (spreadsheetExtensions.has(extension)) {
|
||||
return 'sheet';
|
||||
}
|
||||
|
||||
if (slideExtensions.has(extension)) {
|
||||
return 'slides';
|
||||
}
|
||||
|
||||
if (documentExtensions.has(extension)) {
|
||||
return 'doc';
|
||||
}
|
||||
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
const digits = value >= 10 || unitIndex === 0 ? 0 : 1;
|
||||
return `${value.toFixed(digits)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) {
|
||||
return 'Unavailable';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
async function readFileNode(fullPath, pathSegments) {
|
||||
const stat = await fs.stat(fullPath);
|
||||
|
||||
return {
|
||||
id: createItemId('file', pathSegments),
|
||||
name: path.basename(fullPath),
|
||||
kind: 'file',
|
||||
type: inferFileType(path.basename(fullPath)),
|
||||
owner: LOCAL_OWNER,
|
||||
modified: formatTimestamp(stat.mtimeMs),
|
||||
modifiedMs: stat.mtimeMs,
|
||||
size: formatBytes(stat.size),
|
||||
sizeBytes: stat.size,
|
||||
};
|
||||
}
|
||||
|
||||
async function readDirectoryNode(fullPath, pathSegments, state) {
|
||||
const directoryStat = await fs.stat(fullPath);
|
||||
const children = [];
|
||||
let folderCount = 0;
|
||||
let fileCount = 0;
|
||||
let sizeBytes = 0;
|
||||
let latestModified = directoryStat.mtimeMs ?? 0;
|
||||
|
||||
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
||||
entries.sort(compareDirents);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (state.entryCount >= state.maxEntries) {
|
||||
state.truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryFullPath = path.join(fullPath, entry.name);
|
||||
const entryPathSegments = [...pathSegments, entry.name];
|
||||
state.entryCount += 1;
|
||||
|
||||
try {
|
||||
if (entry.isDirectory()) {
|
||||
const childFolder = await readDirectoryNode(entryFullPath, entryPathSegments, state);
|
||||
children.push(childFolder);
|
||||
folderCount += childFolder.folderCount + 1;
|
||||
fileCount += childFolder.fileCount;
|
||||
sizeBytes += childFolder.sizeBytes ?? 0;
|
||||
latestModified = Math.max(latestModified, childFolder.modifiedMs ?? 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
const fileNode = await readFileNode(entryFullPath, entryPathSegments);
|
||||
children.push(fileNode);
|
||||
fileCount += 1;
|
||||
sizeBytes += fileNode.sizeBytes ?? 0;
|
||||
latestModified = Math.max(latestModified, fileNode.modifiedMs ?? 0);
|
||||
}
|
||||
} catch (error) {
|
||||
state.warnings.push(`Skipped ${entryFullPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: pathSegments.length ? createItemId('folder', pathSegments) : 'root',
|
||||
name: pathSegments[pathSegments.length - 1] || path.basename(fullPath) || fullPath,
|
||||
kind: 'folder',
|
||||
owner: LOCAL_OWNER,
|
||||
modified: formatTimestamp(latestModified),
|
||||
modifiedMs: latestModified,
|
||||
size: formatBytes(sizeBytes),
|
||||
sizeBytes,
|
||||
folderCount,
|
||||
fileCount,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
async function scanLocalDirectory(directoryPath) {
|
||||
const resolvedPath = path.resolve(directoryPath);
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
const error = new Error('The provided path is not a directory.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const state = {
|
||||
entryCount: 0,
|
||||
maxEntries: MAX_ENTRIES,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const tree = await readDirectoryNode(resolvedPath, [], state);
|
||||
|
||||
return {
|
||||
tree: {
|
||||
...tree,
|
||||
id: 'root',
|
||||
name: path.basename(resolvedPath) || resolvedPath,
|
||||
},
|
||||
directoryPath: resolvedPath,
|
||||
truncated: state.truncated,
|
||||
scannedEntries: state.entryCount,
|
||||
warnings: state.warnings.slice(0, 5),
|
||||
};
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
app.get('/api/health', (_request, response) => {
|
||||
response.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/filesystem/scan', async (request, response) => {
|
||||
const directoryPath = typeof request.body?.directoryPath === 'string' ? request.body.directoryPath.trim() : '';
|
||||
|
||||
if (!directoryPath) {
|
||||
response.status(400).json({ error: 'Directory path is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await scanLocalDirectory(directoryPath);
|
||||
response.json(result);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
response.status(404).json({ error: `Directory not found: ${path.resolve(directoryPath)}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (error?.code === 'EACCES' || error?.code === 'EPERM') {
|
||||
response.status(403).json({ error: `Permission denied for: ${path.resolve(directoryPath)}` });
|
||||
return;
|
||||
}
|
||||
|
||||
response.status(error?.statusCode ?? 500).json({ error: error?.message ?? 'Unable to scan the requested directory.' });
|
||||
}
|
||||
});
|
||||
|
||||
if (existsSync(distPath)) {
|
||||
app.use(express.static(distPath));
|
||||
app.get(/^(?!\/api).*/, (_request, response) => {
|
||||
response.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Driveboard local filesystem API listening on http://127.0.0.1:${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user