initial
This commit is contained in:
385
ts/files/file.server.ts
Normal file
385
ts/files/file.server.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
1
ts/files/index.ts
Normal file
1
ts/files/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FileServer } from './file.server.js';
|
||||
Reference in New Issue
Block a user