diff --git a/server/flow-typed/npm/express_v4.17.x.js b/server/flow-typed/npm/express_v4.17.x.js index b59533118..dba830514 100644 --- a/server/flow-typed/npm/express_v4.17.x.js +++ b/server/flow-typed/npm/express_v4.17.x.js @@ -1,349 +1,359 @@ // flow-typed signature: 4a2e9d8f7d4830b8f23ef5921e97806d // flow-typed version: 2f514ea8dd/express_v4.17.x/flow_>=v0.94.x <=v0.103.x declare type express$RouterOptions = { caseSensitive?: boolean, mergeParams?: boolean, strict?: boolean }; declare class express$RequestResponseBase { app: express$Application; get(field: string): string | void; } +declare type express$RangeParserOptions = {| + +combine?: boolean, +|}; + +declare type express$RangeParserRange = {| + +start: number, + +end: number, +|}; + declare type express$RequestParams = { [param: string]: string }; /* NOTE: Use caution when extending `express$Request` or `express$Response`. When a request first hits the server, its `req` and `res` will not have any additional properties, even if you explicitly type them with your custom subclass. Subsequent middleware may assign these properties, but you must be cognizant of this ordering. One way to handle this is marking all properties as optional to force refinement every time a property is accessed. Therefore, we advise that you always mark properties as _optional_ in `express$Request` and `express$Response` subclasses. You may decide not to do this, in which case the typings will be unsound and the behavior will be similar to how arrays work in Flow. See here for more information: https://flow.org/en/docs/types/arrays/#toc-array-access-is-unsafe See #3578 and #3337 for additional discussion. If you have ideas on how to improve these typings, please share them in #3578 or open a new issue. **BAD** declare class test_express$CustomRequest extends express$Request { foo: string; } **GOOD** declare class test_express$CustomRequest extends express$Request { foo: string | void; } */ declare class express$Request extends http$IncomingMessage mixins express$RequestResponseBase { baseUrl: string; body: mixed; cookies: { [cookie: string]: string }; connection: net$Socket; fresh: boolean; hostname: string; ip: string; ips: Array; method: string; originalUrl: string; params: express$RequestParams; path: string; protocol: "https" | "http"; query: { [name: string]: string | Array }; route: string; secure: boolean; signedCookies: { [signedCookie: string]: string }; stale: boolean; subdomains: Array; xhr: boolean; accepts(types: string): string | false; accepts(types: Array): string | false; acceptsCharsets(...charsets: Array): string | false; acceptsEncodings(...encoding: Array): string | false; acceptsLanguages(...lang: Array): string | false; header(field: string): string | void; is(type: string): string | false; param(name: string, defaultValue?: string): string | void; + range(size: number, options?: express$RangeParserOptions): Array | number | void; } declare type express$CookieOptions = { domain?: string, encode?: (value: string) => string, expires?: Date, httpOnly?: boolean, maxAge?: number, path?: string, secure?: boolean, signed?: boolean }; declare type express$Path = string | RegExp; declare type express$RenderCallback = ( err: Error | null, html?: string ) => mixed; declare type express$SendFileOptions = { maxAge?: number, root?: string, lastModified?: boolean, headers?: { [name: string]: string }, dotfiles?: "allow" | "deny" | "ignore" }; declare class express$Response extends http$ServerResponse mixins express$RequestResponseBase { headersSent: boolean; locals: { [name: string]: mixed }; append(field: string, value?: string): this; attachment(filename?: string): this; cookie(name: string, value: string, options?: express$CookieOptions): this; clearCookie(name: string, options?: express$CookieOptions): this; download( path: string, filename?: string, callback?: (err?: ?Error) => void ): this; format(typesObject: { [type: string]: Function }): this; json(body?: mixed): this; jsonp(body?: mixed): this; links(links: { [name: string]: string }): this; location(path: string): this; redirect(url: string, ...args: Array): this; redirect(status: number, url: string, ...args: Array): this; render( view: string, locals?: { [name: string]: mixed }, callback?: express$RenderCallback ): this; send(body?: mixed): this; sendFile( path: string, options?: express$SendFileOptions, callback?: (err?: ?Error) => mixed ): this; sendStatus(statusCode: number): this; header(field: string, value?: string): this; header(headers: { [name: string]: string }): this; set(field: string, value?: string | string[]): this; set(headers: { [name: string]: string }): this; status(statusCode: number): this; type(type: string): this; vary(field: string): this; req: express$Request; } declare type express$NextFunction = (err?: ?Error | "route") => mixed; declare type express$Middleware< Req: express$Request = express$Request, Res: express$Response = express$Response, > = ((req: Req, res: Res, next: express$NextFunction) => mixed) | ((error: Error, req: Req, res: Res, next: express$NextFunction) => mixed); declare interface express$RouteMethodType< T, Req: express$Request = express$Request, Res: express$Response = express$Response, > { (middleware: express$Middleware): T; (...middleware: Array>): T; ( path: express$Path | $ReadOnlyArray, ...middleware: Array> ): T; } declare class express$Route< Req: express$Request = express$Request, Res: express$Response = express$Response, > { all: express$RouteMethodType; get: express$RouteMethodType; post: express$RouteMethodType; put: express$RouteMethodType; head: express$RouteMethodType; delete: express$RouteMethodType; options: express$RouteMethodType; trace: express$RouteMethodType; copy: express$RouteMethodType; lock: express$RouteMethodType; mkcol: express$RouteMethodType; move: express$RouteMethodType; purge: express$RouteMethodType; propfind: express$RouteMethodType; proppatch: express$RouteMethodType; unlock: express$RouteMethodType; report: express$RouteMethodType; mkactivity: express$RouteMethodType; checkout: express$RouteMethodType; merge: express$RouteMethodType; // @TODO Missing 'm-search' but get flow illegal name error. notify: express$RouteMethodType; subscribe: express$RouteMethodType; unsubscribe: express$RouteMethodType; patch: express$RouteMethodType; search: express$RouteMethodType; connect: express$RouteMethodType; } declare class express$Router< Req: express$Request = express$Request, Res: express$Response = express$Response, > extends express$Route { constructor(options?: express$RouterOptions): void; route(path: string): express$Route; static ( options?: express$RouterOptions, ): express$Router; use(middleware: express$Middleware): this; use(...middleware: Array>): this; use( path: express$Path | $ReadOnlyArray, ...middleware: Array> ): this; use(path: string, router: express$Router): this; handle( req: http$IncomingMessage<>, res: http$ServerResponse, next: express$NextFunction ): void; param( param: string, callback: ( req: Req, res: Res, next: express$NextFunction, value: string, paramName: string, ) => mixed ): void; ( req: http$IncomingMessage<>, res: http$ServerResponse, next?: ?express$NextFunction ): void; } /* With flow-bin ^0.59, express app.listen() is deemed to return any and fails flow type coverage. Which is ironic because https://github.com/facebook/flow/blob/master/Changelog.md#misc-2 (release notes for 0.59) says "Improves typings for Node.js HTTP server listen() function." See that? IMPROVES! To work around this issue, we changed Server to ?Server here, so that our invocations of express.listen() will not be deemed to lack type coverage. */ declare class express$Application< Req: express$Request = express$Request, Res: express$Response = express$Response, > extends express$Router mixins events$EventEmitter { constructor(): void; locals: { [name: string]: mixed }; mountpath: string; listen( port: number, hostname?: string, backlog?: number, callback?: (err?: ?Error) => mixed ): ?http$Server; listen( port: number, hostname?: string, callback?: (err?: ?Error) => mixed ): ?http$Server; listen(port: number, callback?: (err?: ?Error) => mixed): ?http$Server; listen(path: string, callback?: (err?: ?Error) => mixed): ?http$Server; listen(handle: Object, callback?: (err?: ?Error) => mixed): ?http$Server; disable(name: string): void; disabled(name: string): boolean; enable(name: string): this; enabled(name: string): boolean; engine(name: string, callback: Function): void; /** * Mixed will not be taken as a value option. Issue around using the GET http method name and the get for settings. */ // get(name: string): mixed; set(name: string, value: mixed): mixed; render( name: string, optionsOrFunction: { [name: string]: mixed }, callback: express$RenderCallback ): void; handle( req: http$IncomingMessage<>, res: http$ServerResponse, next?: ?express$NextFunction ): void; // callable signature is not inherited ( req: http$IncomingMessage<>, res: http$ServerResponse, next?: ?express$NextFunction ): void; } declare type JsonOptions = { inflate?: boolean, limit?: string | number, reviver?: (key: string, value: mixed) => mixed, strict?: boolean, type?: string | Array | ((req: express$Request) => boolean), verify?: ( req: express$Request, res: express$Response, buf: Buffer, encoding: string ) => mixed }; declare type express$UrlEncodedOptions = { extended?: boolean, inflate?: boolean, limit?: string | number, parameterLimit?: number, type?: string | Array | ((req: express$Request) => boolean), verify?: ( req: express$Request, res: express$Response, buf: Buffer, encoding: string ) => mixed, } declare module "express" { declare export type RouterOptions = express$RouterOptions; declare export type CookieOptions = express$CookieOptions; declare export type Middleware< Req: express$Request = express$Request, Res: express$Response = express$Response, > = express$Middleware; declare export type NextFunction = express$NextFunction; declare export type RequestParams = express$RequestParams; declare export type $Response = express$Response; declare export type $Request = express$Request; declare export type $Application< Req: express$Request = express$Request, Res: express$Response = express$Response, > = express$Application; declare module.exports: { // If you try to call like a function, it will use this signature (): express$Application, json: (opts: ?JsonOptions) => express$Middleware<>, // `static` property on the function static: (root: string, options?: Object) => express$Middleware, // `Router` property on the function Router: typeof express$Router, urlencoded: (opts: ?express$UrlEncodedOptions) => express$Middleware<>, }; } diff --git a/server/src/fetchers/upload-fetchers.js b/server/src/fetchers/upload-fetchers.js index 3e7ca4571..2a54563f2 100644 --- a/server/src/fetchers/upload-fetchers.js +++ b/server/src/fetchers/upload-fetchers.js @@ -1,69 +1,119 @@ // @flow import type { Media } from 'lib/types/media-types'; import { ServerError } from 'lib/utils/errors'; import urlFacts from '../../facts/url'; import { dbQuery, SQL } from '../database/database'; import type { Viewer } from '../session/viewer'; const { baseDomain, basePath } = urlFacts; type UploadInfo = {| content: Buffer, mime: string, |}; async function fetchUpload( viewer: Viewer, id: string, secret: string, ): Promise { const query = SQL` SELECT content, mime FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { content, mime } = row; return { content, mime }; } +async function fetchUploadChunk( + id: string, + secret: string, + pos: number, + len: number, +): Promise { + // We use pos + 1 because SQL is 1-indexed whereas js is 0-indexed + const query = SQL` + SELECT SUBSTRING(content, ${pos + 1}, ${len}) AS content, mime + FROM uploads + WHERE id = ${id} AND secret = ${secret} + `; + const [result] = await dbQuery(query); + + if (result.length === 0) { + throw new ServerError('invalid_parameters'); + } + const [row] = result; + const { content, mime } = row; + return { + content, + mime, + }; +} + +// Returns total size in bytes. +async function getUploadSize(id: string, secret: string): Promise { + const query = SQL` + SELECT LENGTH(content) AS length + FROM uploads + WHERE id = ${id} AND secret = ${secret} + `; + const [result] = await dbQuery(query); + + if (result.length === 0) { + throw new ServerError('invalid_parameters'); + } + + const [row] = result; + const { length } = row; + return length; +} + function getUploadURL(id: string, secret: string) { return `${baseDomain}${basePath}upload/${id}/${secret}`; } function mediaFromRow(row: Object): Media { const { uploadType: type, uploadSecret: secret } = row; const { width, height, loop } = row.uploadExtra; const id = row.uploadID.toString(); const dimensions = { width, height }; const uri = getUploadURL(id, secret); if (type === 'photo') { return { id, type: 'photo', uri, dimensions }; } else if (loop) { return { id, type: 'video', uri, dimensions, loop }; } else { return { id, type: 'video', uri, dimensions }; } } async function fetchMedia( viewer: Viewer, mediaIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const query = SQL` SELECT id AS uploadID, secret AS uploadSecret, type AS uploadType, extra AS uploadExtra FROM uploads WHERE id IN (${mediaIDs}) AND uploader = ${viewer.id} AND container IS NULL `; const [result] = await dbQuery(query); return result.map(mediaFromRow); } -export { fetchUpload, getUploadURL, mediaFromRow, fetchMedia }; +export { + fetchUpload, + fetchUploadChunk, + getUploadSize, + getUploadURL, + mediaFromRow, + fetchMedia, +}; diff --git a/server/src/uploads/uploads.js b/server/src/uploads/uploads.js index 2ce7c11bb..d9b423098 100644 --- a/server/src/uploads/uploads.js +++ b/server/src/uploads/uploads.js @@ -1,109 +1,154 @@ // @flow -import type { $Request } from 'express'; +import type { $Request, $Response } from 'express'; +import invariant from 'invariant'; import multer from 'multer'; +import { Readable } from 'stream'; import type { UploadMultimediaResult, UploadDeletionRequest, Dimensions, } from 'lib/types/media-types'; import { ServerError } from 'lib/utils/errors'; import createUploads from '../creators/upload-creator'; import { deleteUpload } from '../deleters/upload-deleters'; -import { fetchUpload } from '../fetchers/upload-fetchers'; +import { + fetchUpload, + fetchUploadChunk, + getUploadSize, +} from '../fetchers/upload-fetchers'; import type { Viewer } from '../session/viewer'; import { validateAndConvert } from './media-utils'; const upload = multer(); const multerProcessor = upload.array('multimedia'); type MulterFile = {| fieldname: string, originalname: string, encoding: string, mimetype: string, buffer: Buffer, size: number, |}; type MultimediaUploadResult = {| results: UploadMultimediaResult[], |}; async function multimediaUploadResponder( viewer: Viewer, req: $Request & { files?: $ReadOnlyArray }, ): Promise { const { files, body } = req; if (!files || !body || typeof body !== 'object') { throw new ServerError('invalid_parameters'); } const overrideFilename = files.length === 1 && body.filename ? body.filename : null; if (overrideFilename && typeof overrideFilename !== 'string') { throw new ServerError('invalid_parameters'); } const inputHeight = files.length === 1 && body.height ? parseInt(body.height) : null; const inputWidth = files.length === 1 && body.width ? parseInt(body.width) : null; if (!!inputHeight !== !!inputWidth) { throw new ServerError('invalid_parameters'); } const inputDimensions: ?Dimensions = inputHeight && inputWidth ? { height: inputHeight, width: inputWidth } : null; const inputLoop = !!(files.length === 1 && body.loop); const validationResults = await Promise.all( files.map(({ buffer, size, originalname }) => validateAndConvert( buffer, overrideFilename ? overrideFilename : originalname, inputDimensions, inputLoop, size, ), ), ); const uploadInfos = validationResults.filter(Boolean); if (uploadInfos.length === 0) { throw new ServerError('invalid_parameters'); } const results = await createUploads(viewer, uploadInfos); return { results }; } async function uploadDownloadResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { const { uploadID, secret } = req.params; if (!uploadID || !secret) { throw new ServerError('invalid_parameters'); } - const { content, mime } = await fetchUpload(viewer, uploadID, secret); - res.type(mime); - res.set('Cache-Control', 'public, max-age=31557600, immutable'); - res.send(content); + + if (!req.headers.range) { + const { content, mime } = await fetchUpload(viewer, uploadID, secret); + res.type(mime); + res.set('Cache-Control', 'public, max-age=31557600, immutable'); + res.send(content); + } else { + const totalUploadSize = await getUploadSize(uploadID, secret); + const range = req.range(totalUploadSize); + if (typeof range === 'number' && range < 0) { + throw new ServerError( + range === -1 ? 'unsatisfiable_range' : 'malformed_header_string', + ); + } + + invariant( + Array.isArray(range), + 'range should be Array in uploadDownloadResponder!', + ); + const { start, end } = range[0]; + const respWidth = end - start + 1; + const { content, mime } = await fetchUploadChunk( + uploadID, + secret, + start, + respWidth, + ); + const respRange = `${start}-${end}/${totalUploadSize}`; + const respHeaders = { + 'Accept-Ranges': 'bytes', + 'Content-Range': `bytes ${respRange}`, + 'Content-Type': mime, + 'Content-Length': respWidth.toString(), + }; + + // HTTP 206 Partial Content + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206 + res.writeHead(206, respHeaders); + const stream = new Readable(); + stream.push(content); + stream.push(null); + stream.pipe(res); + } } async function uploadDeletionResponder( viewer: Viewer, request: UploadDeletionRequest, ): Promise { const { id } = request; await deleteUpload(viewer, id); } export { multerProcessor, multimediaUploadResponder, uploadDownloadResponder, uploadDeletionResponder, };