386 lines
11 KiB
TypeScript
386 lines
11 KiB
TypeScript
/**
|
|
* 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
};
|
|
|
|
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();
|
|
},
|
|
});
|
|
}
|
|
}
|