Files
smartserve/ts/files/file.server.ts

386 lines
11 KiB
TypeScript
Raw Normal View History

2025-11-29 15:24:00 +00:00
/**
* Static file server with streaming, ETags, and directory listing
*/
import * as plugins from '../plugins.js';
import type {
IStaticOptions,
IDirectoryListingOptions,
IFileEntry,
} from '../core/smartserve.interfaces.js';
import { getMimeType } from '../utils/utils.mime.js';
import { generateETag } from '../utils/utils.etag.js';
/**
* Static file server
*/
export class FileServer {
private options: IStaticOptions;
constructor(options: IStaticOptions | string) {
if (typeof options === 'string') {
this.options = { root: options };
} else {
this.options = options;
}
// Set defaults
this.options.index = this.options.index ?? ['index.html', 'index.htm'];
this.options.dotFiles = this.options.dotFiles ?? 'ignore';
this.options.etag = this.options.etag ?? true;
this.options.lastModified = this.options.lastModified ?? true;
}
/**
* Handle a request for static files
*/
async serve(request: Request): Promise<Response | null> {
const url = new URL(request.url);
let pathname = decodeURIComponent(url.pathname);
// Security: prevent path traversal
if (pathname.includes('..')) {
return new Response('Forbidden', { status: 403 });
}
// Resolve file path
const filePath = plugins.path.join(this.options.root, pathname);
// Check if path is within root
const realRoot = plugins.path.resolve(this.options.root);
const realPath = plugins.path.resolve(filePath);
if (!realPath.startsWith(realRoot)) {
return new Response('Forbidden', { status: 403 });
}
try {
const stat = await plugins.fs.promises.stat(realPath);
if (stat.isDirectory()) {
// Try index files
for (const indexFile of this.options.index!) {
const indexPath = plugins.path.join(realPath, indexFile);
try {
const indexStat = await plugins.fs.promises.stat(indexPath);
if (indexStat.isFile()) {
return this.serveFile(request, indexPath, indexStat);
}
} catch {
// Index file doesn't exist, continue
}
}
// Directory listing
if (this.options.directoryListing) {
return this.serveDirectory(request, realPath, pathname);
}
return new Response('Forbidden', { status: 403 });
}
if (stat.isFile()) {
// Check dotfile policy
const basename = plugins.path.basename(realPath);
if (basename.startsWith('.')) {
if (this.options.dotFiles === 'deny') {
return new Response('Forbidden', { status: 403 });
}
if (this.options.dotFiles === 'ignore') {
return null; // Let other handlers try
}
}
return this.serveFile(request, realPath, stat);
}
return null;
} catch (err: any) {
if (err.code === 'ENOENT') {
return null; // File not found, let other handlers try
}
throw err;
}
}
/**
* Serve a single file with proper headers
*/
private async serveFile(
request: Request,
filePath: string,
stat: plugins.fs.Stats
): Promise<Response> {
const headers = new Headers();
// Content-Type
const mimeType = getMimeType(filePath);
headers.set('Content-Type', mimeType);
// Content-Length
headers.set('Content-Length', stat.size.toString());
// Last-Modified
if (this.options.lastModified) {
headers.set('Last-Modified', stat.mtime.toUTCString());
}
// ETag
let etag: string | undefined;
if (this.options.etag) {
etag = generateETag(stat);
headers.set('ETag', etag);
}
// Cache-Control
if (this.options.cacheControl) {
const cacheControl = typeof this.options.cacheControl === 'function'
? this.options.cacheControl(filePath)
: this.options.cacheControl;
headers.set('Cache-Control', cacheControl);
}
// Check conditional requests
const ifNoneMatch = request.headers.get('If-None-Match');
if (etag && ifNoneMatch === etag) {
return new Response(null, { status: 304, headers });
}
const ifModifiedSince = request.headers.get('If-Modified-Since');
if (ifModifiedSince) {
const clientDate = new Date(ifModifiedSince);
if (stat.mtime <= clientDate) {
return new Response(null, { status: 304, headers });
}
}
// Handle Range requests
const rangeHeader = request.headers.get('Range');
if (rangeHeader) {
return this.servePartial(filePath, stat, rangeHeader, headers);
}
// HEAD request
if (request.method === 'HEAD') {
return new Response(null, { status: 200, headers });
}
// Stream the file
const stream = plugins.fs.createReadStream(filePath);
const readableStream = this.nodeStreamToWebStream(stream);
return new Response(readableStream, { status: 200, headers });
}
/**
* Serve partial content (Range request)
*/
private async servePartial(
filePath: string,
stat: plugins.fs.Stats,
rangeHeader: string,
headers: Headers
): Promise<Response> {
const size = stat.size;
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (!match) {
return new Response('Invalid Range', { status: 416 });
}
let start = match[1] ? parseInt(match[1], 10) : 0;
let end = match[2] ? parseInt(match[2], 10) : size - 1;
// Validate range
if (start >= size || end >= size || start > end) {
headers.set('Content-Range', `bytes */${size}`);
return new Response('Range Not Satisfiable', { status: 416, headers });
}
// Set partial content headers
headers.set('Content-Range', `bytes ${start}-${end}/${size}`);
headers.set('Content-Length', (end - start + 1).toString());
headers.set('Accept-Ranges', 'bytes');
const stream = plugins.fs.createReadStream(filePath, { start, end });
const readableStream = this.nodeStreamToWebStream(stream);
return new Response(readableStream, { status: 206, headers });
}
/**
* Serve directory listing
*/
private async serveDirectory(
request: Request,
dirPath: string,
urlPath: string
): Promise<Response> {
const entries = await plugins.fs.promises.readdir(dirPath, { withFileTypes: true });
const files: IFileEntry[] = [];
for (const entry of entries) {
// Skip hidden files unless configured
const listingOptions = typeof this.options.directoryListing === 'object'
? this.options.directoryListing
: {};
if (entry.name.startsWith('.') && !listingOptions.showHidden) {
continue;
}
const entryPath = plugins.path.join(dirPath, entry.name);
const stat = await plugins.fs.promises.stat(entryPath);
files.push({
name: entry.name,
path: plugins.path.join(urlPath, entry.name),
isDirectory: entry.isDirectory(),
size: stat.size,
modified: stat.mtime,
});
}
// Sort files
const listingOptions = typeof this.options.directoryListing === 'object'
? this.options.directoryListing
: {};
const sortBy = listingOptions.sortBy ?? 'name';
const sortOrder = listingOptions.sortOrder ?? 'asc';
files.sort((a, b) => {
// Directories first
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1;
}
let comparison = 0;
switch (sortBy) {
case 'size':
comparison = a.size - b.size;
break;
case 'modified':
comparison = a.modified.getTime() - b.modified.getTime();
break;
default:
comparison = a.name.localeCompare(b.name);
}
return sortOrder === 'desc' ? -comparison : comparison;
});
// Custom template
if (listingOptions.template) {
const result = listingOptions.template(files);
if (result instanceof Response) {
return result;
}
return new Response(result, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
// Default HTML listing
const html = this.generateDirectoryHtml(urlPath, files);
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
/**
* Generate default directory listing HTML
*/
private generateDirectoryHtml(urlPath: string, files: IFileEntry[]): string {
const formatSize = (size: number): string => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
};
const formatDate = (date: Date): string => {
return date.toISOString().replace('T', ' ').slice(0, 19);
};
const escapeHtml = (str: string): string => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
const rows = files.map(file => {
const icon = file.isDirectory ? '📁' : '📄';
const href = encodeURIComponent(file.name) + (file.isDirectory ? '/' : '');
const size = file.isDirectory ? '-' : formatSize(file.size);
return `<tr>
<td>${icon} <a href="${href}">${escapeHtml(file.name)}</a></td>
<td>${size}</td>
<td>${formatDate(file.modified)}</td>
</tr>`;
}).join('\n');
// Add parent directory link if not at root
const parentLink = urlPath !== '/' ? `<tr>
<td>📁 <a href="../">..</a></td>
<td>-</td>
<td>-</td>
</tr>` : '';
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Index of ${escapeHtml(urlPath)}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 2em; }
h1 { font-size: 1.5em; margin-bottom: 1em; }
table { border-collapse: collapse; width: 100%; max-width: 800px; }
th, td { text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #eee; }
th { background: #f5f5f5; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
td:nth-child(2), td:nth-child(3) { white-space: nowrap; }
</style>
</head>
<body>
<h1>Index of ${escapeHtml(urlPath)}</h1>
<table>
<thead>
<tr><th>Name</th><th>Size</th><th>Modified</th></tr>
</thead>
<tbody>
${parentLink}
${rows}
</tbody>
</table>
</body>
</html>`;
}
/**
* Convert Node.js stream to Web ReadableStream
*/
private nodeStreamToWebStream(nodeStream: plugins.fs.ReadStream): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
nodeStream.on('data', (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk));
});
nodeStream.on('end', () => {
controller.close();
});
nodeStream.on('error', (err) => {
controller.error(err);
});
},
cancel() {
nodeStream.destroy();
},
});
}
}