mirror of https://github.com/TriliumNext/Notes
Add first draft (untested).
parent
9a3d218c6e
commit
9422491a44
@ -1,4 +1,4 @@
|
||||
*.log
|
||||
.env
|
||||
node_modules
|
||||
*.log
|
||||
.env
|
||||
node_modules
|
||||
dist
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"editor.formatOnSave": true
|
||||
{
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
@ -1,2 +1,103 @@
|
||||
import { Request } from "node";
|
||||
export function handler(req: Request) {}
|
||||
import { Request, Response } from "express";
|
||||
import { parseRangeHeader, RangeParserError, Range } from "./parseRangeHeader";
|
||||
import { Stream } from "stream";
|
||||
|
||||
/**
|
||||
* @type {function (Request): Promise<Content>}
|
||||
*/
|
||||
export type ContentProvider = (req: Request) => Promise<Content>;
|
||||
|
||||
export class ContentDoesNotExistError extends Error {}
|
||||
|
||||
export interface Logger {
|
||||
debug(message: string, extra?: any): void;
|
||||
}
|
||||
|
||||
export type Content = {
|
||||
/**
|
||||
* Returns a readable stream based on the provided range (optional).
|
||||
* @param {Range} range The start-end range of stream data.
|
||||
* @returns {Stream} A readable stream
|
||||
*/
|
||||
getStream(range?: Range): Stream;
|
||||
/**
|
||||
* Total size of the content
|
||||
*/
|
||||
readonly totalSize: number;
|
||||
/**
|
||||
* Mime type to be sent in Content-Type header
|
||||
*/
|
||||
readonly mimeType: string;
|
||||
/**
|
||||
* File name to be sent in Content-Disposition header
|
||||
*/
|
||||
readonly fileName: string;
|
||||
};
|
||||
|
||||
const getHeader = (name: string, req: Request) => req.headers[name];
|
||||
const getRangeHeader = getHeader.bind(null, "range");
|
||||
const setHeader = (name: string, value: string, res: Response) => res.setHeader(name, value);
|
||||
const setContentTypeHeader = setHeader.bind(null, "Content-Type");
|
||||
const setContentLengthHeader = setHeader.bind(null, "Content-Length");
|
||||
const setAcceptRangesHeader = setHeader.bind(null, "Accept-Ranges", "bytes");
|
||||
const setContentRangeHeader = (range: Range | null, size: number, res: Response) =>
|
||||
setHeader("Content-Range", `bytes ${range ? `${range.start}-${range.end}` : "*"}/${size}`, res);
|
||||
const setContentDispositionHeader = (fileName: string, res: Response) =>
|
||||
setHeader("Content-Disposition", `attachment; filename="${fileName}"`, res);
|
||||
const setCacheControlHeaderNoCache = setHeader.bind(null, "Cache-Control", "no-cache");
|
||||
|
||||
export function create(contentProvider: ContentProvider, logger: Logger) {
|
||||
return async function handler(req: Request, res: Response) {
|
||||
let content;
|
||||
try {
|
||||
content = await contentProvider(req);
|
||||
} catch (error) {
|
||||
logger.debug("ContentProvider threw exception: ", error);
|
||||
if (error instanceof ContentDoesNotExistError) {
|
||||
return res.status(400).send(error.message);
|
||||
}
|
||||
|
||||
return res.sendStatus(500);
|
||||
}
|
||||
|
||||
let { getStream, mimeType, fileName, totalSize } = content;
|
||||
|
||||
const rangeHeader = getRangeHeader(req);
|
||||
let range;
|
||||
try {
|
||||
range = parseRangeHeader(rangeHeader, totalSize);
|
||||
} catch (error) {
|
||||
logger.debug(`parseRangeHeader error: `, error);
|
||||
if (error instanceof RangeParserError) {
|
||||
setContentRangeHeader(null, totalSize, res);
|
||||
|
||||
return res
|
||||
.send(error.message)
|
||||
.status(416)
|
||||
.end();
|
||||
}
|
||||
|
||||
return res.sendStatus(500);
|
||||
}
|
||||
|
||||
let { start, end } = range;
|
||||
|
||||
setContentTypeHeader(mimeType, res);
|
||||
setContentDispositionHeader(fileName, res);
|
||||
setAcceptRangesHeader(res);
|
||||
|
||||
// If range is not specified, or the file is empty, return the full stream
|
||||
if (range === null) {
|
||||
setContentLengthHeader(totalSize, res);
|
||||
return getStream().pipe(res);
|
||||
}
|
||||
|
||||
setContentRangeHeader(range, totalSize, res);
|
||||
setContentLengthHeader(start === end ? 0 : end - start + 1);
|
||||
setCacheControlHeaderNoCache(res);
|
||||
|
||||
// Return 206 Partial Content status
|
||||
res.status(206);
|
||||
getStream(range).pipe(res);
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
export type Range = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
const rangeRegEx = /bytes=([0-9]*)-([0-9]*)/;
|
||||
|
||||
export class RangeParserError extends Error {
|
||||
constructor(start: any, end: any) {
|
||||
super(`Invalid start and end values: ${start}-${end}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRangeHeader(range: string, totalSize: number): Range | null {
|
||||
// 1. If range is not specified or the file is empty, return null.
|
||||
if (range === null || range.length === 0 || totalSize === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [startValue, endValue] = range.split(rangeRegEx);
|
||||
let start = Number.parseInt(startValue);
|
||||
let end = Number.parseInt(endValue);
|
||||
|
||||
// 2. Parse start and end values and ensure they are within limits.
|
||||
// 2.1. start: >= 0.
|
||||
// 2.2. end: >= 0, <= totalSize - 1
|
||||
|
||||
let result = {
|
||||
start: Number.isNaN(start) ? 0 : Math.max(start, 0),
|
||||
end: Number.isNaN(end) ? totalSize - 1 : Math.min(Math.max(end, 0), totalSize - 1)
|
||||
};
|
||||
|
||||
// 3.1. If end is not provided, set end to the last byte (totalSize - 1).
|
||||
if (!Number.isNaN(start) && Number.isNaN(end)) {
|
||||
result.start = start;
|
||||
result.end = totalSize - 1;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3.2. If start is not provided, set it to the offset of last "end" bytes from the end of the file.
|
||||
// And set end to the last byte.
|
||||
// This way we return the last "end" bytes.
|
||||
if (Number.isNaN(start) && !Number.isNaN(end)) {
|
||||
result.start = totalSize - end;
|
||||
result.end = totalSize - 1;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 4. Handle invalid ranges.
|
||||
if (start > end) {
|
||||
throw new RangeParserError(start, end);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,21 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"target": "es6",
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": [
|
||||
"node_modules/*",
|
||||
"src/types/*"
|
||||
]
|
||||
}
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"target": "es6",
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"*": ["../node_modules/*", "./types/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue