/* * 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 . */ 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})`; } }