initial commit

This commit is contained in:
dude
2026-09-01 18:33:56 +00:00
commit c24449ee0a
2761 changed files with 302737 additions and 0 deletions
@@ -0,0 +1,35 @@
const HEADERS = [
'Content-Type',
'Location',
'Tus-Extension',
'Tus-Max-Size',
'Tus-Resumable',
'Tus-Version',
'Upload-Defer-Length',
'Upload-Length',
'Upload-Metadata',
'Upload-Offset',
'X-HTTP-Method-Override',
'X-Requested-With',
];
const MAX_AGE = 86400;
const ALLOWED_HEADERS = HEADERS;
const EXPOSED_HEADERS = HEADERS;
const ALLOWED_METHODS = [
'POST',
'HEAD',
'PATCH',
'OPTIONS',
];
const TUS_VERSION = '1.0.0';
module.exports = {
MAX_AGE,
ALLOWED_HEADERS,
EXPOSED_HEADERS,
ALLOWED_METHODS,
TUS_VERSION,
}
+51
View File
@@ -0,0 +1,51 @@
const createError = require('http-errors');
const tusResumableHeaderMissing = () => createError(412, 'Tus-Resumable Required')
const invalidHeaders = (headers) => (
createError(412, 'Precondition Failed', {
details: { headers },
})
)
const invalidHeader = (header, val) => (
createError(412, 'Precondition Failed', {
details: { headers: [[header, val]] },
})
)
const missingHeader = (header) => invalidHeader(header)
const entityTooLarge = (msg, props) => createError(413, msg, props)
const preconditionError = (msg, props) => createError(412, msg, props)
const offsetMismatch = (actual, expected) => (
createError(409, `Offset mismatch, got ${actual} but expected ${expected}`)
)
// For store implementations:
const unknownResource = (key) => (
createError(404, `Unknown resource: ${key}`)
)
const concurrentWrite = () => (
createError(409, 'Concurrent write detected')
)
const uploadLengthAlreadySet = () => (
createError(409, 'Upload length is already set')
)
module.exports = {
tusResumableHeaderMissing,
invalidHeaders,
invalidHeader,
missingHeader,
entityTooLarge,
preconditionError,
offsetMismatch,
unknownResource,
concurrentWrite,
uploadLengthAlreadySet,
}
@@ -0,0 +1,16 @@
module.exports = (err, req, res, next) => {
if (res.headersSent) return next(err)
if (!err.status) {
// console.error(err)
return next(err)
}
if (!err.expose) return next(err)
if (!(err instanceof Error)) return next(err)
res.statusCode = err.status
res.json({
name: err.name,
message: err.message,
details: err.details,
})
}
@@ -0,0 +1,46 @@
// HEAD
//
// The Server MUST always include the Upload-Offset header in the
// response for a HEAD request, even if the offset is 0, or the upload
// is already considered completed. If the size of the upload is known,
// the Server MUST include the Upload-Length header in the response. If
// the resource is not found, the Server SHOULD return either the 404
// Not Found, 410 Gone or 403 Forbidden status without the Upload-Offset
// header.
//
// The Server MUST prevent the client and/or proxies from caching the
// response by adding the Cache-Control: no-store header to the
// response.
//
const encodeMetadata = require('../tus-metadata').encode;
module.exports = (store) => (
async (req, res) => {
res.set('Cache-Control', 'no-store')
const { uploadId } = req.params
const upload = await store.info(uploadId)
// The Server MUST always include the Upload-Offset header in the
// response for a HEAD request, even if the offset is 0, or the upload
// is already considered completed.
res.set('Upload-Offset', upload.offset)
if (!('uploadLength' in upload)) {
// As long as the length of the upload is not known, the Server
// MUST set Upload-Defer-Length: 1 in all responses to HEAD requests.
res.set('Upload-Defer-Length', '1')
} else {
// If the size of the upload is known, the Server MUST include
// the Upload-Length header in the response
res.set('Upload-Length', upload.uploadLength)
}
// If an upload contains additional metadata, responses to HEAD
// requests MUST include the Upload-Metadata header and its value as
// specified by the Client during the creation.
const encodedMetadata = encodeMetadata(upload.metadata)
if (encodedMetadata !== '') {
res.set('Upload-Metadata', encodedMetadata)
}
res.end()
}
)
@@ -0,0 +1,23 @@
const cors = require('cors')
const { ALLOWED_HEADERS, ALLOWED_METHODS, MAX_AGE } = require ('../constants');
const tusExtension = (extensions = []) => {
if (!extensions.length) {
return (req, res, next) => next()
}
return (req, res, next) => {
res.set('Tus-Extension', extensions.join(','))
next()
}
}
const corsPreflight = (extraMethods) => cors({
methods: [...ALLOWED_METHODS, ...extraMethods],
allowedHeaders: ALLOWED_HEADERS,
maxAge: MAX_AGE,
})
module.exports = (extensions, extraMethods = []) => ([
tusExtension(extensions),
corsPreflight(extraMethods),
])
@@ -0,0 +1,82 @@
// PATCH
//
// The Server SHOULD accept PATCH requests against any upload URL and
// apply the bytes contained in the message at the given offset
// specified by the Upload-Offset header. All PATCH requests MUST use
// Content-Type: application/offset+octet-stream.
//
// The Upload-Offset header’s value MUST be equal to the current offset
// of the resource. In order to achieve parallel upload the
// Concatenation extension MAY be used. If the offsets do not match, the
// Server MUST respond with the 409 Conflict status without modifying
// the upload resource.
//
// The Client SHOULD send all the remaining bytes of an upload in a
// single PATCH request, but MAY also use multiple small requests
// successively for scenarios where this is desirable, for example, if
// the Checksum extension is used.
//
// The Server MUST acknowledge successful PATCH requests with the 204 No
// Content status. It MUST include the Upload-Offset header containing
// the new offset. The new offset MUST be the sum of the offset before
// the PATCH request and the number of bytes received and processed or
// stored during the current PATCH request.
//
// Both, Client and Server, SHOULD attempt to detect and handle network
// errors predictably. They MAY do so by checking for read/write socket
// errors, as well as setting read/write timeouts. A timeout SHOULD be
// handled by closing the underlying connection.
//
// The Server SHOULD always attempt to store as much of the received
// data as possible.
const storeErrors = require('../store/errors');
const errors = require('../errors');
module.exports = (store, {
onComplete,
beforeComplete = async () => {},
afterComplete = async () => {},
}) => (async (req, res) => {
const after = onComplete || afterComplete
// The request MUST include a Upload-Offset header
if (!('uploadOffset' in req.tus)) {
throw errors.missingHeader('upload-offset')
}
// The request MUST include a Content-Type header
if (typeof req.get('content-type') === 'undefined') {
throw errors.missingHeader('content-type')
}
// All PATCH requests MUST use Content-Type: application/offset+octet-stream
if (req.get('content-type') !== 'application/offset+octet-stream') {
throw errors.invalidHeader('content-type', req.get('content-type'))
}
const uploadId = req.params.uploadId
try {
const {
offset,
upload,
} = await store.append(uploadId, req, req.tus.uploadOffset, {
beforeComplete: (...args) => beforeComplete(req, ...args),
uploadLength: req.tus.uploadLength,
})
if (upload && upload.uploadLength === offset) {
await after(req, upload, uploadId)
}
// It MUST include the Upload-Offset header containing the new offset.
res.set('Upload-Offset', offset)
// The Server MUST acknowledge successful PATCH requests
// with the 204 No Content status.
res.status(204)
res.end()
} catch (err) {
if (err instanceof storeErrors.OffsetMismatch) {
throw errors.offsetMismatch()
}
throw err
}
})
@@ -0,0 +1,105 @@
// Creation
//
// The Client and the Server SHOULD implement the upload creation
// extension. If the Server supports this extension, it MUST add creation
// to the Tus-Extension header.
//
// Headers
//
// Upload-Defer-Length
//
// The Upload-Defer-Length request and response header indicates that the
// size of the upload is not known currently and will be transferred later.
// Its value MUST be 1. If the length of an upload is not deferred, this
// header MUST be omitted.
//
// Upload-Metadata
//
// The Upload-Metadata request and response header MUST consist of one or
// more comma-separated key-value pairs. The key and value MUST be
// separated by a space. The key MUST NOT contain spaces and commas and
// MUST NOT be empty. The key SHOULD be ASCII encoded and the value MUST be
// Base64 encoded. All keys MUST be unique.
//
// Requests
//
// POST
//
// The Client MUST send a POST request against a known upload creation URL
// to request a new upload resource. The request MUST include one of the
// following headers:
//
// a) Upload-Length to indicate the size of an entire upload in bytes.
//
// b) Upload-Defer-Length: 1 if upload size is not known at the time. Once
// it is known the Client MUST set the Upload-Length header in the next
// PATCH request. Once set the length MUST NOT be changed. As long as the
// length of the upload is not known, the Server MUST set
// Upload-Defer-Length: 1 in all responses to HEAD requests.
//
// If the Server supports deferring length, it MUST add
// creation-defer-length to the Tus-Extension header.
//
// The Client MAY supply the Upload-Metadata header to add additional
// metadata to the upload creation request. The Server MAY decide to ignore
// or use this information to further process the request or to reject it.
// If an upload contains additional metadata, responses to HEAD requests
// MUST include the Upload-Metadata header and its value as specified by
// the Client during the creation.
//
// If the length of the upload exceeds the maximum, which MAY be specified
// using the Tus-Max-Size header, the Server MUST respond with the 413
// Request Entity Too Large status.
//
// The Server MUST acknowledge a successful upload creation with the 201
// Created status. The Server MUST set the Location header to the URL of
// the created resource. This URL MAY be absolute or relative.
//
// The Client MUST perform the actual upload using the core protocol.
const errors = require('../errors');
module.exports = (store, {
getKey = () => {},
maxUploadLength = Infinity,
}) => async (req, res, next) => {
const tus = req.tus
const { uploadLength } = tus
const defer = !!tus.uploadDeferLength
if (!defer && !('uploadLength' in tus)) {
return next(errors.preconditionError(
'Missing Upload-Length header'
))
}
// TODO: make sure store supports defer length
if (defer && !store.supportsDeferredLength) {
return next(errors.preconditionError(
'Store does not support creation-defer-length extension'
))
}
if (defer && ('uploadLength' in tus)) {
return next(errors.preconditionError(
'Choose one of Upload-Length OR Upload-Defer-Length'
))
}
if ('uploadLength' in tus && uploadLength > maxUploadLength) {
res.set('Tus-Max-Size', maxUploadLength)
return next(errors.entityTooLarge(
`Upload-length (${uploadLength}) exceeds max upload size (${maxUploadLength})`
), { maxUploadLength, uploadLength })
}
const key = await getKey(req)
const metadata = req.tus.uploadMetadata
const { uploadId } = await store.create(key, {
uploadLength,
metadata,
})
res.status(201)
let basePath = req.baseUrl
if (basePath[basePath.length - 1] !== '/') basePath += '/'
res.set('Location', `${basePath}${uploadId}`)
res.end()
}
+90
View File
@@ -0,0 +1,90 @@
const { Router } = require('express');
const tusHeaderParser = require('./tus-header-parser');
const methodOverride = require('method-override');
const cors = require('cors');
const constants = require('./constants');
const errors = require('./errors');
const errorHandler = require ('./handlers/error');
const options = require ('./handlers/options');
const head = require ('./handlers/head');
const patch = require ('./handlers/patch');
const post = require ('./handlers/post');
/**
* Returns a route handler for Express that calls the passed in function
* @param {Function} fn The asynchronous the route needs to call
* @return {Promise}
*/
function w(fn) {
if (fn.length <= 3) {
return function(req, res, next) {
return fn(req, res, next).catch(next);
};
} else {
return function(err, req, res, next) {
return fn(err, req, res, next).catch(next);
};
}
}
const detectExtensions = (store) => {
return [
'create',
() => { if (store.del) return 'delete' },
].filter(ele => typeof ele === 'string')
}
const versionSupported = (/* versionStr */) => true
const setTusResumableHeader = (req, res, next) => {
res.set('Tus-Resumable', constants.TUS_VERSION)
next()
}
// The Tus-Resumable header MUST be included in every request and
// response except for OPTIONS requests. The value MUST be the version
// of the protocol used by the Client or the Server.
const assertTusResumableHeader = (req, res, next) => {
if (!('tusResumable' in req.tus)) {
res.set('Tus-Version', constants.TUS_VERSION)
return next(errors.preconditionError('Tus-Resumable header missing'))
} else if (!versionSupported(req.tus.tusResumable)) {
res.set('Tus-Version', constants.TUS_VERSION)
return next(errors.preconditionError('Tus-Resumable version not supported'))
}
next()
}
const setCorsHeaders = cors({
origin: true,
exposedHeaders: constants.EXPOSED_HEADERS,
})
module.exports = (store, opts = {}) => {
const { handleErrors = true } = opts
const extensions = detectExtensions(store)
const nextRouter = new Router({ mergeParams: true })
.use(assertTusResumableHeader)
.post('/', w(post(store, opts)))
.head('/:uploadId', w(head(store, opts)))
.patch('/:uploadId', w(patch(store, opts)))
if (handleErrors) nextRouter.use(errorHandler)
const router = new Router({ mergeParams: true })
router
.use(methodOverride('X-HTTP-Method-Override'))
.use(tusHeaderParser())
.options('*', options(extensions, opts.extraCorsMethods))
.use(setCorsHeaders)
.use(setTusResumableHeader)
.use((req, res, next) => {
if (req.method === 'GET') return next()
return nextRouter(req, res, next)
})
return router
}
@@ -0,0 +1,20 @@
class StoreError extends Error {
}
class UploadNotFound extends StoreError {
}
class OffsetMismatch extends StoreError {
}
class KeyNotFound extends StoreError {
}
class UploadLocked extends StoreError {
}
module.exports = StoreError;
module.exports.UploadNotFound = UploadNotFound;
module.exports.OffsetMismatch = OffsetMismatch;
module.exports.KeyNotFound = KeyNotFound;
module.exports.UploadLocked = UploadLocked;
@@ -0,0 +1,111 @@
const toObject = require('to-object-reducer');
const decodeMetadata = require('./tus-metadata').decode;
// TODO: Buffer.from silently ignores invalid bytes :(
// https://github.com/nodejs/node/issues/8569
const base64decode = str => Buffer.from(str, 'base64')
const nonNegativeInteger = (v) => {
// need this because parseInt parses floats too
if (v !== '0' && !v.match(/^[1-9][0-9]*$/)) return
const num = parseInt(v, 10)
if (isNaN(num)) return
if (num >= 0) return num
}
/*
const enum = (possibleVals = []) => (v) => {
if (!possibleVals.includes(v)) return
return v
}
*/
const id = v => v
const parsers = {
'tus-resumable': id,
// The Upload-Offset request and response header indicates a byte offset
// within a resource. The value MUST be a non-negative integer.
'upload-offset': nonNegativeInteger,
// The Upload-Defer-Length request and response header indicates that the
// size of the upload is not known currently and will be transferred later.
// Its value MUST be 1. If the length of an upload is not deferred, this
// header MUST be omitted.
'upload-defer-length': v => {
const num = nonNegativeInteger(v)
if (num === 1) return 1
},
// The Upload-Length request and response header indicates the size of the
// entire upload in bytes. The value MUST be a non-negative integer.
'upload-length': nonNegativeInteger,
'upload-metadata': decodeMetadata,
// The Upload-Checksum request header contains information about the checksum
// of the current body payload. The header MUST consist of the name of the
// used checksum algorithm and the Base64 encoded checksum separated by a
// space.
'upload-checksum': v => {
const parts = v.split(' ')
if (parts.length !== 2) return
const algorithm = parts[0]
const checksum = base64decode(parts[1])
if (!checksum.length) return
return { algorithm, checksum }
},
// The Upload-Concat request and response header MUST be set in both partial
// and final upload creation requests. It indicates whether the upload is
// either a partial or final upload. If the upload is a partial one, the
// header value MUST be partial. In the case of a final upload, its value
// MUST be final followed by a semicolon and a space-separated list of
// partial upload URLs that will be concatenated. The partial uploads URLs
// MAY be absolute or relative and MUST NOT contain spaces as defined in RFC
// 3986.
'upload-concat': v => {
if (v === 'partial') return { partial: true }
const parts = v.split(';')
if (!parts.length) return
const firstPart = parts.shift()
if (firstPart !== 'final') return
const urls = parts.join(';').split(' ')
if (urls.some(url => url === '')) return
// validate urls? nah...
return { final: true, urls }
},
}
const knownHeaders = Object.keys(parsers)
const parseHeader = (tmpHeaderName, str) => {
const headerName = tmpHeaderName.toLowerCase()
if (!knownHeaders.includes(headerName)) return
return parsers[headerName](str)
}
// Pre-computed for speed
const camelCase = {
'tus-resumable': 'tusResumable',
'upload-offset': 'uploadOffset',
'upload-defer-length': 'uploadDeferLength',
'upload-length': 'uploadLength',
'upload-metadata': 'uploadMetadata',
'upload-checksum': 'uploadChecksum',
'upload-concat': 'uploadConcat',
}
const parseHeaders = (headers) => (
Object
.keys(headers)
.filter(headerName => knownHeaders.includes(headerName))
.map(headerName => ([
camelCase[headerName],
parsers[headerName](headers[headerName]),
]))
.reduce(toObject, {})
)
module.exports = () => (req, res, next) => {
req.tus = parseHeaders(req.headers)
next()
};
module.exports.knownHeaders = knownHeaders;
module.exports.parseHeader = parseHeader;
module.exports.parseHeaders = parseHeaders;
@@ -0,0 +1,24 @@
const toObject = require('to-object-reducer');
// return object
const base64decode = str => Buffer.from(str, 'base64').toString('utf8')
const base64encode = str => Buffer.from(str, 'utf8').toString('base64')
module.exports.decode = (str = '') => {
const keypairs = str
.split(',')
// .map(s => s.trim())
const keyvals = keypairs
.map(s => s.split(' '))
.filter(arr => arr.length === 2)
.filter(([key]) => key !== '')
.map(([key, val]) => [key, base64decode(val)])
return keyvals.reduce(toObject, {})
}
module.exports.encode = (obj = {}) => Object
.keys(obj)
.map(key => [key, obj[key]])
.map(([key, val]) => [key, base64encode(val)])
.map(kvArr => kvArr.join(' '))
.join(',')