Files
puter/packages/backend/src/api/APIError.js
T

468 lines
16 KiB
JavaScript

/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { quot } = require("../util/strutil");
/**
* APIError represents an error that can be sent to the client.
* @class APIError
* @property {number} status the HTTP status code
* @property {string} message the error message
* @property {object} source the source of the error
*/
module.exports = class APIError {
static codes = {
'item_with_same_name_exists': {
status: 409,
message: ({ entry_name }) => entry_name
? `An item with name ${quot(entry_name)} already exists.`
: 'An item with the same name already exists.'
,
},
'cannot_move_item_into_itself': {
status: 422,
message: 'Cannot move an item into itself.',
},
'cannot_copy_item_into_itself': {
status: 422,
message: 'Cannot copy an item into itself.',
},
'cannot_move_to_root': {
status: 422,
message: 'Cannot move an item to the root directory.',
},
'cannot_copy_to_root': {
status: 422,
message: 'Cannot copy an item to the root directory.',
},
'cannot_write_to_root': {
status: 422,
message: 'Cannot write an item to the root directory.',
},
'cannot_overwrite_a_directory': {
status: 422,
message: 'Cannot overwrite a directory.',
},
'cannot_read_a_directory': {
status: 422,
message: 'Cannot read a directory.',
},
'source_and_dest_are_the_same': {
status: 422,
message: 'Source and destination are the same.',
},
'dest_is_not_a_directory': {
status: 422,
message: 'Destination must be a directory.',
},
'dest_does_not_exist': {
status: 422,
message: 'Destination was not found.',
},
'source_does_not_exist': {
status: 404,
message: 'Source was not found.',
},
'subject_does_not_exist': {
status: 404,
message: 'File or directory not found.',
},
'shortcut_target_not_found': {
status: 404,
message: 'Shortcut target not found.',
},
'shortcut_target_is_a_directory': {
status: 422,
message: 'Shortcut target is a directory; expected a file.',
},
'shortcut_target_is_a_file': {
status: 422,
message: 'Shortcut target is a file; expected a directory.',
},
'forbidden': {
status: 403,
message: 'Permission denied.',
},
'immutable': {
status: 403,
message: 'File is immutable.',
},
'field_empty': {
status: 400,
message: ({ key }) => `Field ${quot(key)} is required.`,
},
'field_missing': {
status: 400,
message: ({ key }) => `Field ${quot(key)} is required.`,
},
'xor_field_missing': {
status: 400,
message: ({ names }) => {
let s = 'One of these mutually-exclusive fields is required: ';
s += names.map(quot).join(', ');
return s;
}
},
'field_only_valid_with_other_field': {
status: 400,
message: ({ key, other_key }) => `Field ${quot(key)} is only valid when field ${quot(other_key)} is specified.`,
},
'invalid_id': {
status: 400,
message: ({ id }) => {
return `Invalid id`;
}
},
'invalid_operation': {
status: 400,
message: ({ operation }) => `Invalid operation: ${quot(operation)}.`,
},
'field_invalid': {
status: 400,
message: ({ key, expected, got }) => {
return `Field ${quot(key)} is invalid.` +
(expected ? ` Expected ${expected}.` : '') +
(got ? ` Got ${got}.` : '')
}
},
'field_immutable': {
status: 400,
message: ({ key }) => `Field ${quot(key)} is immutable.`,
},
'field_too_long': {
status: 400,
message: ({ key, max_length }) => `Field ${quot(key)} is too long. Max length is ${max_length}.`,
},
'field_too_short': {
status: 400,
message: ({ key, min_length }) => `Field ${quot(key)} is too short. Min length is ${min_length}.`,
},
'already_in_use': {
status: 409,
message: ({ what, value }) => `The ${what} ${quot(value)} is already in use.`,
},
'invalid_file_name': {
status: 400,
message: ({ name, reason }) => `Invalid file name: ${quot(name)}${reason ? `; ${reason}` : '.'}`,
},
'storage_limit_reached': {
status: 400,
message: 'Storage capacity limit reached.',
},
'internal_error': {
status: 500,
message: 'An internal error occurred.',
},
'response_timeout': {
status: 504,
message: 'Response timed out.',
},
'file_too_large': {
status: 413,
message: ({ max_size }) => `File too large. Max size is ${max_size} bytes.`,
},
'thumbnail_too_large': {
status: 413,
message: ({ max_size }) => `Thumbnail too large. Max size is ${max_size} bytes.`,
},
'upload_failed': {
status: 500,
message: 'Upload failed.',
},
'missing_expected_metadata': {
status: 400,
message: ({ keys }) => `These fields must come first: ${(keys ?? []).map(quot).join(', ')}.`,
},
'overwrite_and_dedupe_exclusive': {
status: 400,
message: 'Cannot specify both overwrite and dedupe_name.',
},
'not_empty': {
status: 422,
message: 'Directory is not empty.',
},
// Write
'offset_without_existing_file': {
status: 404,
message: 'An offset was specified, but the file doesn\'t exist.',
},
'offset_requires_overwrite': {
status: 400,
message: 'An offset was specified, but overwrite conditions were not met.',
},
'offset_requires_stream': {
status: 400,
message: 'The offset option for write is not available for this upload.'
},
// Batch
'batch_too_many_files': {
status: 400,
message: 'Received an extra file with no corresponding operation.',
},
'batch_missing_file': {
status: 400,
message: 'Missing fileinfo entry or BLOB for operation.',
},
// Open
'no_suitable_app': {
status: 422,
message: ({ entry_name }) => `No suitable app found for ${quot(entry_name)}.`,
},
'app_does_not_exist': {
status: 422,
message: ({ identifier }) => `App ${quot(identifier)} does not exist.`,
},
// Apps
'app_name_already_in_use': {
status: 409,
message: ({ name }) => `App name ${quot(name)} is already in use.`,
},
// Subdomains
'subdomain_limit_reached': {
status: 400,
message: ({ limit }) => `You have exceeded the number of subdomains under your current plan (${limit}).`,
},
'subdomain_reserved': {
status: 400,
message: ({ subdomain }) => `Subdomain ${quot(subdomain)} is not available.`,
},
// Users
'email_already_in_use': {
status: 409,
message: ({ email }) => `Email ${quot(email)} is already in use.`,
},
'username_already_in_use': {
status: 409,
message: ({ username }) => `Username ${quot(username)} is already in use.`,
},
'too_many_username_changes': {
status: 429,
message: 'Too many username changes this month.',
},
'token_invalid': {
status: 400,
message: () => 'Invalid token.',
},
// drivers
'interface_not_found': {
status: 404,
message: ({ interface_name }) => `Interface not found: ${quot(interface_name)}`,
},
'no_implementation_available': {
status: 502,
message: ({ interface_name }) => `No implementation available for interface ${quot(interface_name)}`,
},
'method_not_found': {
status: 404,
message: ({ interface_name, method_name }) => `Method not found: ${quot(method_name)} on interface ${quot(interface_name)}`,
},
'missing_required_argument': {
status: 400,
message: ({ interface_name, method_name, arg_name }) =>
`Missing required argument ${quot(arg_name)} for method ${quot(method_name)} on interface ${quot(interface_name)}`,
},
'argument_consolidation_failed': {
status: 400,
message: ({ interface_name, method_name, arg_name, message }) =>
`Failed to parse or process argument ${quot(arg_name)} for method ${quot(method_name)} on interface ${quot(interface_name)}: ${message}`,
},
// SLA
'rate_limit_exceeded': {
status: 429,
message: ({ method_name, rate_limit }) =>
`Rate limit exceeded for method ${quot(method_name)}: ${rate_limit.max} requests per ${rate_limit.period}ms.`,
},
'monthly_limit_exceeded': {
status: 429,
message: ({ method_key, limit }) =>
`Monthly limit exceeded for method ${quot(method_key)}: ${limit} requests per month.`,
},
'server_rate_exceeded': {
status: 503,
message: 'System-wide rate limit exceeded. Please try again later.',
},
// auth
'token_missing': {
status: 401,
message: 'Missing authentication token.',
},
'token_auth_failed': {
status: 401,
message: 'Authentication failed.',
},
'token_unsupported': {
status: 401,
message: 'This authentication token is not supported here.',
},
'account_suspended': {
status: 403,
message: 'Account suspended.',
},
'permission_denied': {
status: 403,
message: 'Permission denied.',
},
'access_token_empty_permissions': {
status: 403,
message: 'Attempted to create an access token with no permissions.',
},
'invalid_action': {
status: 400,
message: ({ action }) => `Invalid action: ${quot(action)}.`,
},
// Object Mapping
'field_not_allowed_for_create': {
status: 400,
message: ({ key }) => `Field ${quot(key)} is not allowed for create.`,
},
'field_required_for_update': {
status: 400,
message: ({ key }) => `Field ${quot(key)} is required for update.`,
},
'entity_not_found': {
status: 422,
message: ({ identifier }) => `Entity not found: ${quot(identifier)}`,
},
// Chat
// TODO: specifying these errors here might be a violation
// of separation of concerns. Services could register their
// own errors with an error registry.
'max_tokens_exceeded': {
status: 400,
message: ({ input_tokens, max_tokens }) =>
`Input exceeds maximum token count. ` +
`Input has ${input_tokens} tokens, ` +
`but the maximum is ${max_tokens}.`,
},
};
/**
* create() is a factory method for creating APIError instances.
* It accepts either a string or an Error object as the second
* argument. If a string is passed, it is used as the error message.
* If an Error object is passed, its message property is used as the
* error message. The Error object itself is stored in the source
* property. If no second argument is passed, the source property
* is set to null. The first argument is used as the status code.
*
* @static
* @param {number} status
* @param {string|Error} message_or_source one of the following:
* - a string to use as the error message
* - an Error object to use as the source of the error
* - an object with a message property to use as the error message
* @returns
*/
static create (status, source, fields = {}) {
// Just the error code
if ( typeof status === 'string' ) {
const code = this.codes[status];
if ( ! code ) {
return new APIError(500, 'Missing error message.', null, {
code: status
});
}
return new APIError(code.status, status, source, fields);
}
// High-level errors like this: APIError.create(400, '...')
if ( typeof source === 'string' ) {
return new APIError(status, source, null, fields);
}
// Errors from source like this: throw new Error('...')
if (
typeof source === 'object' &&
source instanceof Error
) {
return new APIError(status, source?.message, source, fields);
}
// Errors from sources like this: throw { message: '...', ... }
if (
typeof source === 'object' &&
source.constructor.name === 'Object' &&
source.hasOwnProperty('message')
) {
const allfields = { ...source, ...fields };
return new APIError(status, source.message, source, allfields);
}
console.error('Invalid APIError source:', source);
return new APIError(500, 'Internal Server Error', null, {});
}
static adapt (err) {
if ( err instanceof APIError ) return err;
return APIError.create(`internal_error`);
}
constructor (status, message, source, fields = {}) {
this.codes = this.constructor.codes;
this.status = status;
this._message = message;
this.source = source ?? new Error('error for trace');
this.fields = fields;
if ( this.codes.hasOwnProperty(message) ) {
this.fields.code = message;
this._message = this.codes[message].message;
}
}
write (res) {
const message = typeof this.message === 'function'
? this.message(this.fields)
: this.message;
return res.status(this.status).send({
message,
...this.fields,
});
}
serialize () {
console.log('MESSAGE FROM ERROR: ' + `|${this.message}|`);
return {
...this.fields,
$: 'heyputer:api/APIError',
message: this.message,
status: this.status,
};
}
get message () {
const message = typeof this._message === 'function'
? this._message(this.fields)
: this._message;
return message;
}
toString () {
return `APIError(${this.status}, ${this.message})`;
}
}