actionhero
Version:
The reusable, scalable, and quick node.js API server for stateless and stateful applications
254 lines (226 loc) • 7.15 kB
text/typescript
import * as fs from "fs";
import * as path from "path";
import * as Mime from "mime";
import { api, config, log, Initializer } from "../index";
import { Connection } from "./../classes/connection";
import { PluginConfig } from "../classes/config";
export interface StaticFileApi {
searchLocations: Array<string>;
get?: StaticFileInitializer["get"];
sendFile?: StaticFileInitializer["sendFile"];
searchPath?: StaticFileInitializer["searchPath"];
checkExistence?: StaticFileInitializer["checkExistence"];
sendFileNotFound?: StaticFileInitializer["sendFileNotFound"];
logRequest?: StaticFileInitializer["logRequest"];
fileLogger?: StaticFileInitializer["fileLogger"];
}
/**
* Contains helpers for returning flies to connections.
*/
export class StaticFileInitializer extends Initializer {
constructor() {
super();
this.name = "staticFile";
this.loadPriority = 510;
}
/**
* For a connection with `connection.params.file` set, return a file if we can find it, or a not-found message.
* `searchLocations` will be checked in the following order: first paths in this project, then plugins.
* This can be used in Actions to return files to clients. If done, set `data.toRender = false` within the action.
* return is of the form: {connection, error, fileStream, mime, length}
*/
get = async (
connection: Connection,
counter = 0,
): Promise<{
connection: Connection;
error?: any;
mime: string;
length: any;
fileStream?: fs.ReadStream;
lastModified?: Date;
}> => {
let file: string;
if (!connection.params.file || !api.staticFile.searchPath(counter)) {
return api.staticFile.sendFileNotFound(
connection,
await config.errors.fileNotProvided(connection),
);
}
if (!path.isAbsolute(connection.params.file)) {
file = path.normalize(
path.join(api.staticFile.searchPath(counter), connection.params.file),
);
} else {
file = connection.params.file;
}
if (
file.indexOf(path.normalize(api.staticFile.searchPath(counter))) !== 0
) {
return api.staticFile.get(connection, counter + 1);
} else {
const { exists, truePath } = await api.staticFile.checkExistence(file);
if (exists) {
return api.staticFile.sendFile(truePath, connection);
} else {
return api.staticFile.get(connection, counter + 1);
}
}
};
searchPath = (counter = 0) => {
if (
api.staticFile.searchLocations.length === 0 ||
counter >= api.staticFile.searchLocations.length
) {
return null;
} else {
return api.staticFile.searchLocations[counter];
}
};
sendFile = async (file: string, connection: Connection) => {
let lastModified: Date;
try {
const stats = await asyncStats(file);
const mime = Mime.getType(file);
const length = stats.size;
const start = new Date().getTime();
lastModified = stats.mtime;
const fileStream = fs.createReadStream(file);
api.staticFile.fileLogger(fileStream, connection, start, file, length);
await new Promise((resolve) => {
fileStream.on("open", () => {
resolve(null);
});
});
return { connection, fileStream, mime, length, lastModified };
} catch (error) {
return api.staticFile.sendFileNotFound(
connection,
await config.errors.fileReadError(connection, error),
);
}
};
fileLogger = (
fileStream: any,
connection: Connection,
start: number,
file: string,
length: number | bigint,
) => {
fileStream.on("end", () => {
const duration = new Date().getTime() - start;
api.staticFile.logRequest(file, connection, length, duration, true);
});
fileStream.on("error", (error: NodeJS.ErrnoException) => {
throw error;
});
};
sendFileNotFound = async (connection: Connection, errorMessage: string) => {
connection.error = new Error(errorMessage);
api.staticFile.logRequest("{not found}", connection, null, null, false);
const response = await config.errors.fileNotFound(connection);
return {
connection,
error: response,
mime: "text/html",
length: response.length,
};
};
checkExistence = async (
file: string,
): Promise<{ exists: boolean; truePath: string }> => {
try {
const stats = await asyncStats(file);
if (stats.isDirectory()) {
const indexPath = file + "/" + config.general.directoryFileType;
return api.staticFile.checkExistence(indexPath);
}
if (stats.isSymbolicLink()) {
let truePath = await asyncReadLink(file);
truePath = path.normalize(truePath);
return api.staticFile.checkExistence(truePath);
}
if (stats.isFile()) {
return { exists: true, truePath: file };
}
return { exists: false, truePath: file };
} catch (error) {
return { exists: false, truePath: file };
}
};
logRequest = (
file: string,
connection: Connection,
length: number | bigint,
duration: number,
success: boolean,
) => {
log(`[ file @ ${connection.type} ]`, config.general.fileRequestLogLevel, {
to: connection.remoteIP,
file: file,
requestedFile: connection.params.file,
size: length,
duration: duration,
success: success,
});
};
async initialize() {
api.staticFile = {
searchLocations: [],
get: this.get,
searchPath: this.searchPath,
sendFile: this.sendFile,
fileLogger: this.fileLogger,
sendFileNotFound: this.sendFileNotFound,
checkExistence: this.checkExistence,
logRequest: this.logRequest,
};
// load in the explicit public paths first
if (config.general.paths) {
config.general.paths.public.forEach(function (p: string) {
api.staticFile.searchLocations.push(path.normalize(p));
});
}
// source the public directories from plugins
for (const plugin of Object.values(config.plugins as PluginConfig)) {
if (plugin.public !== false) {
const pluginPublicPath: string = path.normalize(
path.join(plugin.path, "public"),
);
if (
fs.existsSync(pluginPublicPath) &&
api.staticFile.searchLocations.indexOf(pluginPublicPath) < 0
) {
api.staticFile.searchLocations.push(pluginPublicPath);
}
}
}
log(
"static files will be served from these directories",
"debug",
api.staticFile.searchLocations,
);
}
}
async function asyncStats(
file: string,
): Promise<ReturnType<typeof fs.statSync>> {
return new Promise((resolve, reject) => {
fs.stat(file, (error, stats) => {
if (error) {
return reject(error);
}
return resolve(stats);
});
});
}
async function asyncReadLink(file: string): Promise<string> {
return new Promise((resolve, reject) => {
fs.readlink(file, (error, linkString) => {
if (error) {
return reject(error);
}
return resolve(linkString);
});
});
}