mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-28 16:17:05 +00:00
468 lines
16 KiB
JavaScript
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})`;
|
|
}
|
|
}
|