Comparing version 0.4.1 to 0.4.2
import { access, constants, lstat, readdir, readFile, realpath, stat } from 'node:fs/promises'; | ||
import { join } from 'node:path'; | ||
import { isAbsolute, join, relative, sep as dirSep } from 'node:path'; | ||
import { fileURLToPath } from 'node:url'; | ||
import { trimSlash } from './utils.js'; | ||
@@ -36,7 +37,2 @@ /** | ||
/** @type {(moduleUrl: URL | string) => string} */ | ||
export function moduleDirname(moduleUrl) { | ||
return fileURLToPath(new URL('.', moduleUrl)); | ||
} | ||
/** @type {(dirPath: string) => Promise<FSEntryBase[]>} */ | ||
@@ -65,2 +61,10 @@ export async function getIndex(dirPath) { | ||
/** @type {(root: string, filePath: string) => string | null} */ | ||
export function getLocalPath(root, filePath) { | ||
if (isSubpath(root, filePath)) { | ||
return trimSlash(filePath.slice(root.length), { start: true, end: true }); | ||
} | ||
return null; | ||
} | ||
/** @type {(filePath: string) => Promise<string | null>} */ | ||
@@ -91,2 +95,14 @@ export async function getRealpath(filePath) { | ||
/** @type {(parent: string, filePath: string) => boolean} */ | ||
export function isSubpath(parent, filePath) { | ||
if (filePath.includes('..') || !isAbsolute(filePath)) return false; | ||
parent = trimSlash(parent, { end: true }); | ||
return filePath === parent || filePath.startsWith(parent + dirSep); | ||
} | ||
/** @type {(moduleUrl: URL | string) => string} */ | ||
export function moduleDirname(moduleUrl) { | ||
return fileURLToPath(new URL('.', moduleUrl)); | ||
} | ||
/** @type {(localPath: string) => string} */ | ||
@@ -93,0 +109,0 @@ export function pkgFilePath(localPath) { |
@@ -119,4 +119,4 @@ import { release } from 'node:os'; | ||
/** @type {(data: ReqResMeta) => string} */ | ||
export function requestLogLine({ startedAt, endedAt, status, method, urlPath, file, error }) { | ||
const { brackets, style } = color; | ||
export function requestLogLine({ startedAt, endedAt, status, method, urlPath, localPath, error }) { | ||
const { style: _, brackets } = color; | ||
@@ -127,9 +127,9 @@ const isSuccess = status >= 200 && status < 300; | ||
let displayPath = style(urlPath, 'cyan'); | ||
if (isSuccess && file?.localPath) { | ||
let displayPath = _(urlPath, 'cyan'); | ||
if (isSuccess && localPath != null) { | ||
const basePath = urlPath.length > 1 ? trimSlash(urlPath, { end: true }) : urlPath; | ||
const suffix = pathSuffix(basePath, `/${fwdSlash(file.localPath)}`); | ||
const suffix = pathSuffix(basePath, `/${fwdSlash(localPath)}`); | ||
if (suffix) { | ||
displayPath = style(basePath, 'cyan') + brackets(suffix, 'dim,gray,dim'); | ||
if (urlPath.length > 1 && urlPath.endsWith('/')) displayPath += style('/', 'cyan'); | ||
displayPath = _(basePath, 'cyan') + brackets(suffix, 'dim,gray,dim'); | ||
if (urlPath.length > 1 && urlPath.endsWith('/')) displayPath += _('/', 'cyan'); | ||
} | ||
@@ -139,8 +139,8 @@ } | ||
const line = [ | ||
style(timestamp, 'dim'), | ||
style(`${status}`, isSuccess ? 'green' : 'red'), | ||
style('—', 'dim'), | ||
style(method, 'cyan'), | ||
_(timestamp, 'dim'), | ||
_(`${status}`, isSuccess ? 'green' : 'red'), | ||
_('—', 'dim'), | ||
_(method, 'cyan'), | ||
displayPath, | ||
duration >= 0 ? style(`(${duration}ms)`, 'dim') : undefined, | ||
duration >= 0 ? _(`(${duration}ms)`, 'dim') : undefined, | ||
] | ||
@@ -151,3 +151,3 @@ .filter(Boolean) | ||
if (!isSuccess && error) { | ||
return `${line}\n${style(error.toString(), 'red')}`; | ||
return `${line}\n${_(error.toString(), 'red')}`; | ||
} | ||
@@ -154,0 +154,0 @@ return line; |
@@ -8,3 +8,2 @@ import { basename, dirname } from 'node:path'; | ||
@typedef {import('./types.d.ts').DirIndexItem} DirIndexItem | ||
@typedef {import('./types.d.ts').ResolvedFile} ResolvedFile | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
@@ -81,7 +80,7 @@ */ | ||
/** | ||
@param {{ urlPath: string; file: ResolvedFile; items: DirIndexItem[] }} data | ||
@param {{ urlPath: string; filePath: string; items: DirIndexItem[] }} data | ||
@param {Pick<ServerOptions, 'root' | 'ext'>} options | ||
@returns {Promise<string>} | ||
*/ | ||
export function dirListPage({ urlPath, file, items }, options) { | ||
export function dirListPage({ urlPath, filePath, items }, options) { | ||
const rootName = basename(options.root); | ||
@@ -97,4 +96,3 @@ const trimmedUrl = trimSlash(urlPath); | ||
sorted.unshift({ | ||
filePath: dirname(file.filePath), | ||
localPath: file.localPath && dirname(file.localPath), | ||
filePath: dirname(filePath), | ||
kind: 'dir', | ||
@@ -101,0 +99,0 @@ isParent: true, |
@@ -1,4 +0,4 @@ | ||
import { join, sep as dirSep } from 'node:path'; | ||
import { isAbsolute, join } from 'node:path'; | ||
import { getIndex, getKind, getRealpath, isReadable } from './fs-utils.js'; | ||
import { getIndex, getKind, getLocalPath, getRealpath, isReadable, isSubpath } from './fs-utils.js'; | ||
import { PathMatcher } from './path-matcher.js'; | ||
@@ -34,2 +34,4 @@ import { fwdSlash, trimSlash } from './utils.js'; | ||
throw new Error('Missing root directory'); | ||
} else if (!isAbsolute(options.root)) { | ||
throw new Error('Expected absolute root path'); | ||
} | ||
@@ -45,45 +47,36 @@ this.#root = trimSlash(options.root, { end: true }); | ||
async find(url) { | ||
const urlPath = this.cleanUrlPath(url); | ||
const { urlPath, filePath: targetPath } = resolveUrlPath(this.#root, url); | ||
/** @type {ResolveResult} */ | ||
const result = { | ||
urlPath: urlPath ?? url, | ||
urlPath, | ||
status: 404, | ||
file: null, | ||
filePath: null, | ||
kind: null, | ||
}; | ||
const targetPath = this.urlToTargetPath(urlPath); | ||
if (!urlPath || !targetPath || !this.withinRoot(targetPath)) { | ||
if (targetPath == null) { | ||
return result; | ||
} | ||
let resource = await this.locateFile(targetPath); | ||
const isSymlink = resource.kind === 'link'; | ||
if (isSymlink) { | ||
const filePath = await getRealpath(resource.filePath); | ||
const kind = filePath ? await getKind(filePath) : null; | ||
if (filePath) { | ||
resource = { filePath, kind }; | ||
// Locate file (following symlinks) | ||
let file = await this.locateFile(targetPath); | ||
if (file.kind === 'link') { | ||
const realPath = await getRealpath(file.filePath); | ||
const real = realPath != null ? await this.locateFile(realPath) : null; | ||
if (real?.kind === 'file' || real?.kind === 'dir') { | ||
file = real; | ||
} | ||
} | ||
if (resource.kind === 'dir' || resource.kind === 'file') { | ||
const localPath = this.localPath(resource.filePath); | ||
// We have a match | ||
if (file.kind === 'file' || file.kind === 'dir') { | ||
Object.assign(result, file); | ||
} | ||
result.file = { | ||
filePath: resource.filePath, | ||
localPath: this.localPath(resource.filePath), | ||
kind: resource.kind, | ||
}; | ||
const enabled = resource.kind === 'file' || (resource.kind === 'dir' && this.#dirList); | ||
if (enabled && this.allowedLocalPath(localPath)) { | ||
const readable = await isReadable(resource.filePath, resource.kind); | ||
result.status = readable ? 200 : 403; | ||
} else if (isSymlink) { | ||
result.status = 403; | ||
} | ||
// Check permissions (directories are always a 404 if dirList is false) | ||
if (file.kind === 'file' || (file.kind === 'dir' && this.#dirList)) { | ||
const allowed = this.allowedPath(file.filePath); | ||
const readable = allowed && (await isReadable(file.filePath, file.kind)); | ||
result.status = allowed ? (readable ? 200 : 403) : 404; | ||
} | ||
@@ -99,11 +92,6 @@ | ||
/** @type {DirIndexItem[]} */ | ||
const items = []; | ||
const items = (await getIndex(dirPath)).filter( | ||
(item) => item.kind != null && this.allowedPath(item.filePath), | ||
); | ||
for (const { kind, filePath } of await getIndex(dirPath)) { | ||
const localPath = this.localPath(filePath); | ||
if (kind != null && this.allowedLocalPath(localPath)) { | ||
items.push({ filePath, localPath, kind }); | ||
} | ||
} | ||
items.sort((a, b) => a.filePath.localeCompare(b.filePath)); | ||
@@ -116,9 +104,5 @@ | ||
const filePath = await getRealpath(item.filePath); | ||
const kind = filePath ? await getKind(filePath) : null; | ||
if (filePath != null && kind != null) { | ||
item.target = { | ||
kind, | ||
filePath, | ||
localPath: this.localPath(filePath), | ||
}; | ||
if (filePath != null && this.withinRoot(filePath)) { | ||
const kind = await getKind(filePath); | ||
item.target = { filePath, kind }; | ||
} | ||
@@ -132,66 +116,45 @@ } | ||
/** | ||
Locate alternative files that can be served for a resource, | ||
using the config for extensions and index file lookup. | ||
@type {(fullPath: string) => Promise<FSEntryBase>} | ||
@type {(filePath: string[]) => Promise<FSEntryBase | void>} | ||
*/ | ||
async locateFile(fullPath) { | ||
const targetKind = await getKind(fullPath); | ||
if (targetKind === 'file' || targetKind === 'link') { | ||
return { kind: targetKind, filePath: fullPath }; | ||
} | ||
/** @type {string[]} */ | ||
let candidates = []; | ||
if (targetKind === 'dir' && this.#dirFile.length) { | ||
candidates = this.#dirFile.map((name) => join(fullPath, name)); | ||
} else if (targetKind === null && this.#ext.length) { | ||
candidates = this.#ext.map((ext) => fullPath + ext); | ||
} | ||
for (const filePath of candidates) { | ||
async locateAltFiles(filePaths) { | ||
for (const filePath of filePaths) { | ||
if (!this.withinRoot(filePath)) continue; | ||
const kind = await getKind(filePath); | ||
if (kind === 'file' || kind === 'link') { | ||
return { kind, filePath }; | ||
return { filePath, kind }; | ||
} | ||
} | ||
return { kind: targetKind, filePath: fullPath }; | ||
} | ||
/** @type {(localPath: string | null) => boolean} */ | ||
allowedLocalPath(localPath) { | ||
if (typeof localPath === 'string') { | ||
return this.#excludeMatcher.test(localPath) === false; | ||
/** | ||
Locate a file or alternative files that can be served for a resource, | ||
using the config for extensions and index file lookup. | ||
@type {(filePath: string) => Promise<FSEntryBase>} | ||
*/ | ||
async locateFile(filePath) { | ||
if (!this.withinRoot(filePath)) { | ||
return { filePath, kind: null }; | ||
} | ||
return false; | ||
} | ||
/** @type {(urlPath: string) => boolean} */ | ||
allowedUrlPath(urlPath) { | ||
const forbidden = ['/', '\\', '..']; | ||
const segments = urlPath | ||
.split('/') | ||
.filter(Boolean) | ||
.map((s) => decodeURIComponent(s)); | ||
return segments.every((s) => forbidden.every((f) => !s.includes(f))); | ||
} | ||
const kind = await getKind(filePath); | ||
/** @type {(url: string) => string | null} */ | ||
cleanUrlPath(url) { | ||
try { | ||
const path = fwdSlash(new URL(url, 'http://localhost/').pathname); | ||
if (this.allowedUrlPath(path)) { | ||
return path.startsWith('/') ? path : `/${path}`; | ||
} | ||
} catch {} | ||
return null; | ||
// Try alternates | ||
if (kind === 'dir' && this.#dirFile.length) { | ||
const paths = this.#dirFile.map((name) => join(filePath, name)); | ||
const match = await this.locateAltFiles(paths); | ||
if (match) return match; | ||
} else if (kind === null && this.#ext.length) { | ||
const paths = this.#ext.map((ext) => filePath + ext); | ||
const match = await this.locateAltFiles(paths); | ||
if (match) return match; | ||
} | ||
return { filePath, kind }; | ||
} | ||
/** @type {(fullPath: string) => string | null} */ | ||
localPath(fullPath) { | ||
if (this.withinRoot(fullPath)) { | ||
return fullPath.slice(this.#root.length + 1); | ||
} | ||
return null; | ||
/** @type {(filePath: string) => boolean} */ | ||
allowedPath(filePath) { | ||
const localPath = getLocalPath(this.#root, filePath); | ||
if (localPath == null) return false; | ||
return this.#excludeMatcher.test(localPath) === false; | ||
} | ||
@@ -208,8 +171,31 @@ | ||
/** @type {(fullPath: string) => boolean} */ | ||
withinRoot(fullPath) { | ||
if (fullPath.includes('..')) return false; | ||
const prefix = this.#root + dirSep; | ||
return fullPath === this.#root || fullPath.startsWith(prefix); | ||
/** @type {(filePath: string) => boolean} */ | ||
withinRoot(filePath) { | ||
return isSubpath(this.#root, filePath); | ||
} | ||
} | ||
/** @type {(urlPath: string) => boolean} */ | ||
export function isValidUrlPath(urlPath) { | ||
if (urlPath === '/') return true; | ||
if (!urlPath.startsWith('/') || urlPath.includes('//')) return false; | ||
for (const s of trimSlash(urlPath).split('/')) { | ||
const d = decodeURIComponent(s); | ||
if (d === '.' || d === '..') return false; | ||
if (s.includes('?') || s.includes('#')) return false; | ||
if (d.includes('/') || d.includes('\\')) return false; | ||
} | ||
return true; | ||
} | ||
/** @type {(root: url, url: string) => {urlPath: string; filePath: string | null}} */ | ||
export function resolveUrlPath(root, url) { | ||
try { | ||
const urlPath = fwdSlash(new URL(url, 'http://localhost/').pathname) ?? '/'; | ||
const filePath = isValidUrlPath(urlPath) | ||
? trimSlash(join(root, decodeURIComponent(urlPath)), { end: true }) | ||
: null; | ||
return { urlPath, filePath }; | ||
} catch {} | ||
return { urlPath: url, filePath: null }; | ||
} |
@@ -9,2 +9,3 @@ import { Buffer } from 'node:buffer'; | ||
import { getContentType, typeForFilePath } from './content-type.js'; | ||
import { getLocalPath, getRealpath, isSubpath } from './fs-utils.js'; | ||
import { dirListPage, errorPage } from './pages.js'; | ||
@@ -20,4 +21,5 @@ import { PathMatcher } from './path-matcher.js'; | ||
@typedef {import('./content-type.js').TypeResult} TypeResult | ||
@typedef {import('./types.d.ts').FSEntryBase} FSEntryBase | ||
@typedef {import('./types.d.ts').FSEntryKind} FSEntryKind | ||
@typedef {import('./types.d.ts').ReqResMeta} ReqResMeta | ||
@typedef {import('./types.d.ts').ResolvedFile} ResolvedFile | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
@@ -63,8 +65,7 @@ | ||
urlPath = ''; | ||
/** @type {string | null} */ | ||
filePath = null; | ||
/** @type {FSEntryKind | null} */ | ||
kind = null; | ||
/** | ||
File matching the requested urlPath, if found and readable | ||
@type {ResolvedFile | null} | ||
*/ | ||
file = null; | ||
/** | ||
Error that may be logged to the terminal | ||
@@ -110,2 +111,8 @@ @type {Error | string | undefined} | ||
} | ||
get localPath() { | ||
if (this.filePath != null) { | ||
return getLocalPath(this.#options.root, this.filePath); | ||
} | ||
return null; | ||
} | ||
@@ -127,15 +134,16 @@ async process() { | ||
const { status, urlPath, file } = await this.#resolver.find(this.url); | ||
const { status, urlPath, filePath, kind } = await this.#resolver.find(this.url); | ||
this.status = status; | ||
this.urlPath = urlPath; | ||
this.file = file; | ||
this.filePath = filePath; | ||
this.kind = kind; | ||
// found a file to serve | ||
if (status === 200 && file?.kind === 'file' && file.localPath != null) { | ||
return this.#sendFile(file); | ||
if (status === 200 && filePath != null && kind === 'file') { | ||
return this.#sendFile(filePath); | ||
} | ||
// found a directory that we can show a listing for | ||
else if (status === 200 && file?.kind === 'dir' && file.localPath != null) { | ||
return this.#sendListPage(file); | ||
else if (status === 200 && filePath != null && kind === 'dir') { | ||
return this.#sendListPage(filePath); | ||
} | ||
@@ -146,4 +154,4 @@ | ||
/** @type {(file: ResolvedFile) => Promise<void>} */ | ||
async #sendFile(file) { | ||
/** @type {(filePath: string) => Promise<void>} */ | ||
async #sendFile(filePath) { | ||
/** @type {FileHandle | undefined} */ | ||
@@ -157,7 +165,10 @@ let handle; | ||
try { | ||
// check that we can actually open the file | ||
// (especially on windows where it might be busy) | ||
handle = await open(file.filePath); | ||
statSize = (await stat(file.filePath)).size; | ||
contentType = await getContentType({ path: file.filePath, handle }); | ||
// already checked in resolver, but better safe than sorry | ||
if (!isSubpath(this.#options.root, filePath)) { | ||
throw new Error(`File '${filePath}' is not contained in root: '${this.#options.root}'`); | ||
} | ||
// check that we can open the file (especially on windows where it might be busy) | ||
handle = await open(filePath); | ||
statSize = (await stat(filePath)).size; | ||
contentType = await getContentType({ path: filePath, handle }); | ||
} catch (/** @type {any} */ err) { | ||
@@ -174,3 +185,3 @@ this.status = err?.code === 'EBUSY' ? 403 : 500; | ||
this.#setHeaders(file.localPath ?? file.filePath, { | ||
this.#setHeaders(filePath, { | ||
contentType: contentType?.toString(), | ||
@@ -189,3 +200,3 @@ cors: this.#options.cors, | ||
else if (this.method !== 'HEAD' && !this.#options._noStream) { | ||
data.body = createReadStream(file.filePath, { autoClose: true, start: 0 }); | ||
data.body = createReadStream(filePath, { autoClose: true, start: 0 }); | ||
} | ||
@@ -196,4 +207,4 @@ | ||
/** @type {(dir: ResolvedFile) => Promise<void>} */ | ||
async #sendListPage(dir) { | ||
/** @type {(filePath: string) => Promise<void>} */ | ||
async #sendListPage(filePath) { | ||
this.#setHeaders('index.html', { | ||
@@ -209,7 +220,5 @@ cors: false, | ||
const items = await this.#resolver.index(dir.filePath); | ||
return this.#send({ | ||
body: await dirListPage({ urlPath: this.urlPath, file: dir, items }, this.#options), | ||
isText: true, | ||
}); | ||
const items = await this.#resolver.index(filePath); | ||
const body = await dirListPage({ urlPath: this.urlPath, filePath, items }, this.#options); | ||
return this.#send({ body, isText: true }); | ||
} | ||
@@ -301,5 +310,5 @@ | ||
Set all response headers, except for content-length | ||
@type {(localPath: string, options: Partial<{ contentType: string, cors: boolean; headers: ServerOptions['headers'] }>) => void} | ||
@type {(filePath: string, options: Partial<{ contentType: string, cors: boolean; headers: ServerOptions['headers'] }>) => void} | ||
*/ | ||
#setHeaders(localPath, { contentType, cors, headers }) { | ||
#setHeaders(filePath, { contentType, cors, headers }) { | ||
if (this.#res.headersSent) return; | ||
@@ -315,3 +324,3 @@ | ||
if (!isOptions) { | ||
contentType ??= typeForFilePath(localPath).toString(); | ||
contentType ??= typeForFilePath(filePath).toString(); | ||
this.#header('content-type', contentType); | ||
@@ -324,3 +333,4 @@ } | ||
if (localPath && headerRules.length) { | ||
const localPath = getLocalPath(this.#options.root, filePath); | ||
if (localPath != null && headerRules.length) { | ||
const blockList = ['content-encoding', 'content-length']; | ||
@@ -349,4 +359,4 @@ for (const { name, value } of fileHeaders(localPath, headerRules, blockList)) { | ||
data() { | ||
const { startedAt, endedAt, status, method, url, urlPath, file, error } = this; | ||
return { startedAt, endedAt, status, method, url, urlPath, file, error }; | ||
const { status, method, url, urlPath, localPath, startedAt, endedAt, error } = this; | ||
return { status, method, url, urlPath, localPath, startedAt, endedAt, error }; | ||
} | ||
@@ -353,0 +363,0 @@ } |
@@ -1,4 +0,4 @@ | ||
export type DirIndexItem = ResolvedFile & { | ||
export type DirIndexItem = FSEntryBase & { | ||
isParent?: boolean; | ||
target?: ResolvedFile; | ||
target?: FSEntryBase; | ||
}; | ||
@@ -54,19 +54,17 @@ | ||
urlPath: string; | ||
file: ResolvedFile | null; | ||
filePath: string | null; | ||
kind: FSEntryKind | null; | ||
} | ||
export type ReqResMeta = ResolveResult & { | ||
readonly startedAt: number; | ||
export type ReqResMeta = { | ||
method: string; | ||
status: number; | ||
url: string; | ||
urlPath: string; | ||
localPath: string | null; | ||
startedAt: number; | ||
endedAt?: number; | ||
readonly method: string; | ||
readonly url: string; | ||
error?: Error | string; | ||
}; | ||
export interface ResolvedFile { | ||
kind: FSEntryKind; | ||
filePath: string; | ||
localPath: string | null; | ||
} | ||
export interface HttpOptions { | ||
@@ -73,0 +71,0 @@ host: string; |
{ | ||
"name": "servitsy", | ||
"version": "0.4.1", | ||
"version": "0.4.2", | ||
"license": "MIT", | ||
@@ -45,4 +45,4 @@ "description": "Small, local HTTP server for static files", | ||
"devDependencies": { | ||
"@types/node": "^20.17.1", | ||
"fs-fixture": "^2.5.0", | ||
"@types/node": "^20.17.6", | ||
"fs-fixture": "^2.6.0", | ||
"linkedom": "^0.18.5", | ||
@@ -49,0 +49,0 @@ "prettier": "^3.3.3", |
@@ -70,3 +70,3 @@ # servitsy | ||
| [sirv-cli] | 3.0.0 | 12 | 396 kB | | ||
| [serve] | 14.2.3 | 89 | 7.6 MB | | ||
| [serve] | 14.2.4 | 87 | 7.5 MB | | ||
| [http-server] | 14.1.1 | 45 | 8.9 MB | | ||
@@ -73,0 +73,0 @@ |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
83340
2460