/* * 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 { v4: uuidv4 } = require('uuid'); const _path = require('path'); const micromatch = require('micromatch'); const config = require('./config') const mime = require('mime-types'); const PerformanceMonitor = require('./monitor/PerformanceMonitor.js'); const { generate_identifier } = require('./util/identifier.js'); const { ManagedError } = require('./util/errorutil.js'); const { spanify } = require('./util/otelutil.js'); const APIError = require('./api/APIError.js'); const { DB_READ, DB_WRITE } = require('./services/database/consts.js'); const { BaseDatabaseAccessService } = require('./services/database/BaseDatabaseAccessService.js'); const { LLRmNode } = require('./filesystem/ll_operations/ll_rmnode'); const { Context } = require('./util/context'); const { NodeUIDSelector } = require('./filesystem/node/selectors'); let systemfs = null; let services = null; const tmp_provide_services = async ss => { services = ss; await services.ready; systemfs = services.get('filesystem').get_systemfs(); } async function is_empty(dir_uuid){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'filesystem'); // first check if this entry is shared let rows = await db.read( `SELECT EXISTS(SELECT 1 FROM fsentries WHERE parent_uid = ? LIMIT 1) AS not_empty`, [dir_uuid] ); return !rows[0].not_empty; } /** * @deprecated - sharing will be implemented with user-to-user ACL */ async function has_shared_with(user_id, recipient_user_id){ return false; } /** * Checks to see if this file/directory is shared with the user identified by `recipient_user_id` * * @param {*} fsentry_id * @param {*} recipient_user_id * * @deprecated - sharing will be implemented with user-to-user ACL */ async function is_shared_with(fsentry_id, recipient_user_id){ return false; } /** * Checks to see if this file/directory is shared with at least one other user * * @param {*} fsentry_id * @param {*} recipient_user_id * * @deprecated - sharing will be implemented with user-to-user ACL */ async function is_shared_with_anyone(fsentry_id){ return false; } const chkperm = spanify('chkperm', async (target_fsentry, requester_user_id, action) => { // basic cases where false is the default response if(!target_fsentry) return false; // pseudo-entry from FSNodeContext if ( target_fsentry.is_root ) { return action === 'read'; } // requester is the owner of this entry if(target_fsentry.user_id === requester_user_id){ return true; } // this entry was shared with the requester else if(await is_shared_with(target_fsentry.id, requester_user_id)){ return true; } // special case: owner of entry has shared at least one entry with requester and requester is asking for the owner's root directory: /[owner_username] else if(target_fsentry.parent_uid === null && await has_shared_with(target_fsentry.user_id, requester_user_id) && action !== 'write') return true; else return false; }); /** * Checks if the string provided is a valid FileSystem Entry name. * * @param {string} name * @returns */ function validate_fsentry_name(name){ if(!name) throw {message: 'Name can not be empty.'} else if(!isString(name)) throw {message: "Name can only be a string."} else if(name.includes('/')) throw {message: "Name can not contain the '/' character."} else if(name === '.') throw {message: "Name can not be the '.' character."}; else if(name === '..') throw {message: "Name can not be the '..' character."}; else if(name.length > config.max_fsentry_name_length) throw {message: `Name can not be longer than ${config.max_fsentry_name_length} characters`} else return true } /** * Convert a FSEntry ID to UUID * * @param {integer} id - `id` of FSEntry * @returns {Promise} Promise object represents the UUID of the FileSystem Entry */ async function id2uuid(id){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'filesystem'); let fsentry = await db.requireRead("SELECT `uuid`, immutable FROM `fsentries` WHERE `id` = ? LIMIT 1", [id]); if(!fsentry[0]) return null; else return fsentry[0].uuid; } /** * Get total data stored by a user * * @param {integer} user_id - `user_id` of user * @returns {Promise} Promise object represents the UUID of the FileSystem Entry */ async function df(user_id){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'filesystem'); const fsentry = await db.read("SELECT SUM(size) AS total FROM `fsentries` WHERE `user_id` = ? LIMIT 1", [user_id]); if(!fsentry[0] || !fsentry[0].total) return 0; else return fsentry[0].total; } /** * Get user by a variety of IDs * * Pass `cached: false` to options if a cached user entry would not be appropriate; * for example: when performing authentication. * * @param {string} options - `options` * @returns {Promise} */ async function get_user(options){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'filesystem'); let user; const cached = options.cached ?? true; if ( cached && ! options.force ) { if (options.username) user = kv.get('users:username:' + options.username); else if (options.email) user = kv.get('users:email:' + options.email); else if (options.uuid) user = kv.get('users:uuid:' + options.uuid); else if (options.id) user = kv.get('users:id:' + options.id); else if (options.referral_code) user = kv.get('users:referral_code:' + options.referral_code); if ( user ) return user; } if ( ! options.force ) { if(options.username) user = await db.read("SELECT * FROM `user` WHERE `username` = ? LIMIT 1", [options.username]); else if(options.email) user = await db.read("SELECT * FROM `user` WHERE `email` = ? LIMIT 1", [options.email]); else if(options.uuid) user = await db.read("SELECT * FROM `user` WHERE `uuid` = ? LIMIT 1", [options.uuid]); else if(options.id) user = await db.read("SELECT * FROM `user` WHERE `id` = ? LIMIT 1", [options.id]); else if(options.referral_code) user = await db.read("SELECT * FROM `user` WHERE `referral_code` = ? LIMIT 1", [options.referral_code]); } if(!user || !user[0]){ if(options.username) user = await db.pread("SELECT * FROM `user` WHERE `username` = ? LIMIT 1", [options.username]) else if(options.email) user = await db.pread("SELECT * FROM `user` WHERE `email` = ? LIMIT 1", [options.email]); else if(options.uuid) user = await db.pread("SELECT * FROM `user` WHERE `uuid` = ? LIMIT 1", [options.uuid]); else if(options.id) user = await db.pread("SELECT * FROM `user` WHERE `id` = ? LIMIT 1", [options.id]); else if(options.referral_code) user = await db.pread("SELECT * FROM `user` WHERE `referral_code` = ? LIMIT 1", [options.referral_code]); } user = user ? user[0] : null; if ( ! user ) return user; try { kv.set('users:username:' + user.username, user); kv.set('users:email:' + user.email, user); kv.set('users:uuid:' + user.uuid, user); kv.set('users:id:' + user.id, user); kv.set('users:referral_code:' + user.referral_code, user); } catch (e) { console.error(e); } return user; } /** * Invalidate the cached entries for a user object * * @param {User} userID - the user entry to invalidate */ function invalidate_cached_user (user) { kv.del('users:username:' + user.username); kv.del('users:uuid:' + user.uuid); kv.del('users:email:' + user.email); kv.del('users:id:' + user.id); } /** * Invalidate the cached entries for the user specified by an id * @param {number} id - the id of the user to invalidate */ function invalidate_cached_user_by_id (id) { const user = kv.get('users:id:' + id); if ( ! user ) return; invalidate_cached_user(user); } /** * Refresh apps cache * * @param {string} options - `options` * @returns {Promise} */ async function refresh_apps_cache(options, override){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'apps'); const log = services.get('log-service').create('refresh_apps_cache'); log.tick('refresh apps cache'); // if options is not provided, refresh all apps if(!options){ let apps = await db.read('SELECT * FROM apps'); for (let index = 0; index < apps.length; index++) { const app = apps[index]; kv.set('apps:name:' + app.name, app); kv.set('apps:id:' + app.id, app); kv.set('apps:uid:' + app.uid, app); } } // refresh only apps that are approved for listing else if(options.only_approved_for_listing){ let apps = await db.read('SELECT * FROM apps WHERE approved_for_listing = 1'); for (let index = 0; index < apps.length; index++) { const app = apps[index]; kv.set('apps:name:' + app.name, app); kv.set('apps:id:' + app.id, app); kv.set('apps:uid:' + app.uid, app); } } // if options is provided, refresh only the app specified else{ let app; if(options.name) app = await db.read('SELECT * FROM apps WHERE name = ?', [options.name]); else if(options.uid) app = await db.read('SELECT * FROM apps WHERE uid = ?', [options.uid]); else if(options.id) app = await db.read('SELECT * FROM apps WHERE id = ?', [options.id]); else { log.error('invalid options to refresh_apps_cache'); throw new Error('Invalid options provided'); } if(!app || !app[0]) { log.error('refresh_apps_cache could not find the app'); return; } else { app = app[0]; if ( override ) { Object.assign(app, override); } kv.set('apps:name:' + app.name, app); kv.set('apps:id:' + app.id, app); kv.set('apps:uid:' + app.uid, app); } } } async function refresh_associations_cache(){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'apps'); const log = services.get('log-service').create('refresh_apps_cache'); log.tick('refresh associations cache'); const associations = await db.read('SELECT * FROM app_filetype_association'); const lists = {}; for ( const association of associations ) { let ext = association.type; if ( ext.startsWith('.') ) ext = ext.slice(1); if ( ! lists.hasOwnProperty(ext) ) lists[ext] = []; lists[ext].push(association.app_id); } for ( const k in lists ) { kv.set(`assocs:${k}:apps`, lists[k]); } } /** * Get App by a variety of IDs * * @param {string} options - `options` * @returns {Promise} */ async function get_app(options){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'apps'); const log = services.get('log-service').create('get_app'); let app = []; if(options.uid){ // try cache first app[0] = kv.get(`apps:uid:${options.uid}`); // not in cache, try db if(!app[0]) { log.cache(false, 'apps:uid:' + options.uid); app = await db.read("SELECT * FROM `apps` WHERE `uid` = ? LIMIT 1", [options.uid]); } }else if(options.name){ // try cache first app[0] = kv.get(`apps:name:${options.name}`); // not in cache, try db if(!app[0]) { log.cache(false, 'apps:name:' + options.name); app = await db.read("SELECT * FROM `apps` WHERE `name` = ? LIMIT 1", [options.name]); } } else if(options.id){ // try cache first app[0] = kv.get(`apps:id:${options.id}`); // not in cache, try db if(!app[0]) { log.cache(false, 'apps:id:' + options.id); app = await db.read("SELECT * FROM `apps` WHERE `id` = ? LIMIT 1", [options.id]); } } app = app && app[0] ? app[0] : null; if ( app === null ) return null; // shallow clone because we use the `delete` operator // and it corrupts the cache otherwise app = { ...app }; return app; } /** * Checks to see if an app exists * * @param {string} options - `options` * @returns {Promise} */ async function app_exists(options){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'apps'); let app; if(options.uid) app = await db.read("SELECT `id` FROM `apps` WHERE `uid` = ? LIMIT 1", [options.uid]); else if(options.name) app = await db.read("SELECT `id` FROM `apps` WHERE `name` = ? LIMIT 1", [options.name]); else if(options.id) app = await db.read("SELECT `id` FROM `apps` WHERE `id` = ? LIMIT 1", [options.id]); return app[0]; } /** * change username * * @param {string} options - `options` * @returns {Promise} */ async function change_username(user_id, new_username){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_WRITE, 'auth'); const old_username = (await get_user({id: user_id})).username; // update username await db.write("UPDATE `user` SET username = ? WHERE `id` = ? LIMIT 1", [new_username, user_id]); // update root directory name for this user await db.write("UPDATE `fsentries` SET `name` = ? WHERE `user_id` = ? AND parent_uid IS NULL LIMIT 1", [new_username, user_id]); const log = services.get('log-service').create('change_username'); log.noticeme(`User ${old_username} changed username to ${new_username}`); await services.get('filesystem').update_child_paths(`/${old_username}`, `/${new_username}`, user_id); invalidate_cached_user_by_id(user_id); } /** * Find a FSEntry by its uuid * * @param {integer} id - `id` of FSEntry * @returns {Promise} Promise object represents the UUID of the FileSystem Entry * @deprecated Use fs middleware instead */ async function uuid2fsentry(uuid, return_thumbnail){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'filesystem'); // todo optim, check if uuid is not exactly 36 characters long, if not it's invalid // and we can avoid one unnecessary DB lookup let fsentry = await db.requireRead( `SELECT id, associated_app_id, uuid, public_token, bucket, bucket_region, file_request_token, user_id, parent_uid, is_dir, is_public, is_shortcut, shortcut_to, sort_by, ${return_thumbnail ? 'thumbnail,' : ''} immutable, name, metadata, modified, created, accessed, size FROM fsentries WHERE uuid = ? LIMIT 1`, [uuid] ); if(!fsentry[0]) return false; else return fsentry[0]; } /** * Find a FSEntry by its id * * @param {integer} id - `id` of FSEntry * @returns {Promise} Promise object represents the UUID of the FileSystem Entry */ async function id2fsentry(id, return_thumbnail){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'filesystem'); // todo optim, check if uuid is not exactly 36 characters long, if not it's invalid // and we can avoid one unnecessary DB lookup let fsentry = await db.requireRead( `SELECT id, uuid, public_token, file_request_token, associated_app_id, user_id, parent_uid, is_dir, is_public, is_shortcut, shortcut_to, sort_by, ${return_thumbnail ? 'thumbnail,' : ''} immutable, name, metadata, modified, created, accessed, size FROM fsentries WHERE id = ? LIMIT 1`, [id] ); if(!fsentry[0]){ return false; }else return fsentry[0]; } /** * Takes a an absolute path and returns its corresponding FSEntry. * * @param {string} path - absolute path of the filesystem entry to be resolved * @param {boolean} return_content - if FSEntry is a file, determines whether its content should be returned * @returns {false|object} - `false` if path could not be resolved, otherwise an object representing the FSEntry * @deprecated Use fs middleware instead */ async function convert_path_to_fsentry(path){ // todo optim, check if path is valid (e.g. contaisn valid characters) // if syntactical errors are found we can potentially avoid some expensive db lookups // '/' means that parent_uid is null // TODO: facade fsentry for root (devlog:2023-06-01) if(path === '/') return null; //first slash is redundant path = path.substr(path.indexOf('/') + 1) //last slash, if existing is redundant if(path[path.length - 1] === '/') path = path.slice(0, -1); //split path into parts const fsentry_names = path.split('/'); // if no parts, return false if(fsentry_names.length === 0) return false; let parent_uid = null; let final_res = null; let is_public = false let result /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'filesystem'); // Try stored path first result = await db.read( `SELECT * FROM fsentries WHERE path=? LIMIT 1`, ['/' + path], ); if ( result[0] ) { return result[0]; } for(let i=0; i < fsentry_names.length; i++){ if(parent_uid === null){ result = await db.read( `SELECT * FROM fsentries WHERE parent_uid IS NULL AND name=? LIMIT 1`, [fsentry_names[i]] ); } else{ result = await db.read( `SELECT * FROM fsentries WHERE parent_uid = ? AND name=? LIMIT 1`, [parent_uid, fsentry_names[i]] ); } if(result[0] ){ parent_uid = result[0].uuid; // is_public is either directly specified or inherited from parent dir if(result[0].is_public === null) result[0].is_public = is_public else is_public = result[0].is_public }else{ return false; } final_res = result } return final_res[0]; } /** * * @param {integer} bytes - size in bytes * @returns {string} bytes in human-readable format */ function byte_format(bytes){ // calculate and return bytes in human-readable format const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; if (typeof bytes !== "number" || bytes < 1) { return '0 B'; } const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i]; }; const get_dir_size = async (path, user)=>{ let size = 0; const descendants = await get_descendants(path, user); for(let i=0; i < descendants.length; i++){ if(!descendants[i].is_dir){ size += descendants[i].size; } } return size; } /** * Recursively retrieve all files, directories, and subdirectories under `path`. * Optionally the `depth` can be set. * * @param {string} path * @param {object} user * @param {integer} depth * @returns */ const get_descendants_0 = async (path, user, depth, return_thumbnail = false) => { const log = services.get('log-service').create('get_descendants'); log.called(); // decrement depth if it's set depth !== undefined && depth--; // turn path into absolute form path = _path.resolve('/', path) // get parent dir const parent = await convert_path_to_fsentry(path); // holds array that will be returned const ret = []; // holds immediate children of this path let children; // try to extract username from path let username; let split_path = path.split('/'); if(split_path.length === 2 && split_path[0] === '') username = split_path[1]; /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'filesystem'); // ------------------------------------- // parent is root ('/') // ------------------------------------- if(parent === null){ path = ''; // direct children under root children = await db.read( `SELECT id, uuid, parent_uid, name, metadata, is_dir, bucket, bucket_region, modified, created, immutable, shortcut_to, is_shortcut, sort_by, associated_app_id, ${return_thumbnail ? 'thumbnail, ' : ''} accessed, size FROM fsentries WHERE user_id = ? AND parent_uid IS NULL`, [user.id] ); // users that have shared files/dirs with this user const sharing_users = await db.read( `SELECT DISTINCT(owner_user_id), user.username FROM share INNER JOIN user ON user.id = share.owner_user_id WHERE share.recipient_user_id = ?`, [user.id] ); if(sharing_users.length>0){ for(let i=0; i0){ for(let i=0; i0){ for(let i=0; i child.id); const qmarks = ids.map(() => '?').join(','); let rows = await db.read( `SELECT root_dir_id FROM subdomains WHERE root_dir_id IN (${qmarks}) AND user_id=?`, [...ids, user.id]); log.debug('rows???', rows); const websiteMap = {}; for ( const row of rows ) websiteMap[row.root_dir_id] = true; for(let i=0; i 0)) ){ ret.push(await get_descendants(path + '/' + children[i].name, user, depth)) } } return ret.flat(); } const get_descendants = async (...args) => { const tracer = services.get('traceService').tracer; let ret; await tracer.startActiveSpan('get_descendants', async span => { ret = await get_descendants_0(...args); span.end(); }); return ret; } /** * * @param {integer} entry_id * @returns */ const id2path = async (entry_uid)=>{ if ( entry_uid == null ) { throw new Error('got null or undefined entry id'); } /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'filesystem'); const traces = services.get('traceService'); const log = services.get('log-service').create('helpers.id2path'); log.traceOn(); const errors = services.get('error-service').create(log); log.called(); let result; return await traces.spanify(`helpers:id2path`, async () => { log.debug(`entry id: ${entry_uid}`) if ( typeof entry_uid === 'number' ) { const old = entry_uid; entry_uid = await id2uuid(entry_uid); log.debug(`entry id resolved: resolved ${old} ${entry_uid}`) } try { result = await db.read(` WITH RECURSIVE cte AS ( SELECT uuid, parent_uid, name, name AS path FROM fsentries WHERE uuid = ? UNION ALL SELECT e.uuid, e.parent_uid, e.name, ${ db.case({ sqlite: `e.name || '/' || cte.path`, otherwise: `CONCAT(e.name, '/', cte.path)`, }) } FROM fsentries e INNER JOIN cte ON cte.parent_uid = e.uuid ) SELECT * FROM cte WHERE parent_uid IS NULL `, [entry_uid]); } catch (e) { errors.report('id2path.select', { alarm: true, source: e, message: `error while resolving path for ${entry_uid}: ${e.message}`, extra: { entry_uid, } }); throw new ManagedError(`cannot create path for ${entry_uid}`); } if ( ! result || ! result[0] ) { errors.report('id2path.select', { alarm: true, message: `no result for ${entry_uid}`, extra: { entry_uid, } }); throw new ManagedError(`cannot create path for ${entry_uid}`); } return '/' + result[0].path; }) } /** * * @param {string} glob * @param {object} user * @returns */ async function resolve_glob(glob, user){ //turn glob into abs path glob = _path.resolve('/', glob) //get base of glob const base = micromatch.scan(glob).base //estimate needed depth let depth = 1 const dirs = glob.split('/') for(let i=0; i< dirs.length; i++){ if(dirs[i].includes('**')){ depth = undefined break }else{ depth++ } } const descendants = await get_descendants(base, user, depth) return descendants.filter((fsentry) => { return fsentry.path && micromatch.isMatch(fsentry.path, glob) }) } /** * Copies a FSEntry represented by `source_path` to `dest_path`. * * @param {string} source_path * @param {string} dest_path * @param {object} user * @returns */ function cp(source_path, dest_path, user, overwrite, change_name, check_perms = true){ throw new Error(`legacy copy function called`); } function isString(variable) { return typeof variable === 'string' || variable instanceof String; } // checks to see if given variable is an object function isObject(variable) { return variable !== null && typeof variable === 'object'; } /** * Recusrively deletes all files under `path` * * @param {string} source_path * @param {object} user * @returns */ function rm(source_path, user, descendants_only = false){ throw new Error(`legacy remove function called`); } const body_parser_error_handler = (err, req, res, next) => { if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { return res.status(400).send(err); // Bad request } next(); } async function is_ancestor_of(ancestor_uid, descendant_uid){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'filesystem'); // root is an ancestor to all FSEntries if(ancestor_uid === null) return true; // root is never a descendant to any FSEntries if(descendant_uid === null) return false; if ( typeof ancestor_uid === 'number' ) { ancestor_uid = await id2uuid(ancestor_uid); } if ( typeof descendant_uid === 'number' ) { descendant_uid = await id2uuid(descendant_uid); } let parent = await db.read("SELECT `uuid`, `parent_uid` FROM `fsentries` WHERE `uuid` = ? LIMIT 1", [descendant_uid]); if(parent[0] === undefined) parent = await db.pread("SELECT `uuid`, `parent_uid` FROM `fsentries` WHERE `uuid` = ? LIMIT 1", [descendant_uid]); if(parent[0].uuid === ancestor_uid || parent[0].parent_uid === ancestor_uid){ return true; } // keep checking as long as parent of parent is not root while(parent[0].parent_uid !== null){ parent = await db.read("SELECT `uuid`, `parent_uid` FROM `fsentries` WHERE `uuid` = ? LIMIT 1", [parent[0].parent_uid]); if(parent[0] === undefined) { parent = await db.pread("SELECT `uuid`, `parent_uid` FROM `fsentries` WHERE `uuid` = ? LIMIT 1", [descendant_uid]); } if(parent[0].uuid === ancestor_uid || parent[0].parent_uid === ancestor_uid){ return true; } } return false; } async function sign_file(fsentry, action){ const sha256 = require('js-sha256').sha256; // fsentry not found if(fsentry === false){ throw {message: 'No entry found with this uid'}; } const uid = fsentry.uuid ?? (fsentry.uid ?? fsentry._id); const ttl = 9999999999999; const secret = config.url_signature_secret; const expires = Math.ceil(Date.now() / 1000) + ttl; const signature = sha256(`${uid}/${action}/${secret}/${expires}`); const contentType = mime.contentType(fsentry.name); // return return { uid: uid, expires: expires, signature: signature, url: `${config.api_base_url}/file?uid=${uid}&expires=${expires}&signature=${signature}`, read_url: `${config.api_base_url}/file?uid=${uid}&expires=${expires}&signature=${signature}`, write_url: `${config.api_base_url}/writeFile?uid=${uid}&expires=${expires}&signature=${signature}`, metadata_url: `${config.api_base_url}/itemMetadata?uid=${uid}&expires=${expires}&signature=${signature}`, fsentry_type: contentType, fsentry_is_dir: !! fsentry.is_dir, fsentry_name: fsentry.name, fsentry_size: fsentry.size, fsentry_accessed: fsentry.accessed, fsentry_modified: fsentry.modified, fsentry_created: fsentry.created, } } async function gen_public_token(file_uuid, ttl = 24 * 60 * 60){ const { v4: uuidv4 } = require('uuid'); // get fsentry let fsentry = await uuid2fsentry(file_uuid); // fsentry not found if(fsentry === false){ throw {message: 'No entry found with this uid'}; } const uid = fsentry.uuid; const expires = Math.ceil(Date.now() / 1000) + ttl; const token = uuidv4(); const contentType = mime.contentType(fsentry.name); /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_WRITE, 'filesystem'); // insert into DB try{ await db.write( `UPDATE fsentries SET public_token = ? WHERE id = ?`, [ //token token, //fsentry_id fsentry.id, ]); }catch(e){ console.log(e); return false; } // return return { uid: uid, token: token, url: `${config.api_base_url}/pubfile?token=${token}`, fsentry_type: contentType, fsentry_is_dir: fsentry.is_dir, fsentry_name: fsentry.name, } } async function deleteUser(user_id){ console.log('THIS IS deleteUser ---'); /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'filesystem'); // get a list of all files owned by this user let files = await db.read( `SELECT uuid, bucket, bucket_region FROM fsentries WHERE user_id = ? AND is_dir = 0`, [user_id] ); // delete all files from S3 if(files !== null && files.length > 0){ for(let i=0; i { if ( ! fsentry.name ) { const fs = require('fs'); fs.writeFileSync('/tmp/missing-fsentry-name.txt', JSON.stringify(fsentry, null, 2)); return 'missing-fsentry-name'; } let fsname = fsentry.name.toLowerCase(); // We add `.directory` so that this works as a file association if ( fsentry.is_dir ) fsname += '.directory'; return fsname; })(); const file_extension = _path.extname(fsname).toLowerCase(); //--------------------------------------------- // Code //--------------------------------------------- if( fsname.endsWith('.asm') || fsname.endsWith('.asp') || fsname.endsWith('.aspx') || fsname.endsWith('.bash') || fsname.endsWith('.c') || fsname.endsWith('.cpp') || fsname.endsWith('.css') || fsname.endsWith('.csv') || fsname.endsWith('.dhtml') || fsname.endsWith('.f') || fsname.endsWith('.go') || fsname.endsWith('.h') || fsname.endsWith('.htm') || fsname.endsWith('.html') || fsname.endsWith('.html5') || fsname.endsWith('.java') || fsname.endsWith('.jl') || fsname.endsWith('.js') || fsname.endsWith('.jsa') || fsname.endsWith('.json') || fsname.endsWith('.jsonld') || fsname.endsWith('.jsf') || fsname.endsWith('.jsp') || fsname.endsWith('.kt') || fsname.endsWith('.log') || fsname.endsWith('.lock') || fsname.endsWith('.lua') || fsname.endsWith('.md') || fsname.endsWith('.perl') || fsname.endsWith('.phar') || fsname.endsWith('.php') || fsname.endsWith('.pl') || fsname.endsWith('.py') || fsname.endsWith('.r') || fsname.endsWith('.rb') || fsname.endsWith('.rdata') || fsname.endsWith('.rda') || fsname.endsWith('.rdf') || fsname.endsWith('.rds') || fsname.endsWith('.rs') || fsname.endsWith('.rlib') || fsname.endsWith('.rpy') || fsname.endsWith('.scala') || fsname.endsWith('.sc') || fsname.endsWith('.scm') || fsname.endsWith('.sh') || fsname.endsWith('.sol') || fsname.endsWith('.sql') || fsname.endsWith('.ss') || fsname.endsWith('.svg') || fsname.endsWith('.swift') || fsname.endsWith('.toml') || fsname.endsWith('.ts') || fsname.endsWith('.wasm') || fsname.endsWith('.xhtml') || fsname.endsWith('.xml') || fsname.endsWith('.yaml') || // files with no extension !fsname.includes('.') ){ suggested_apps.push(await get_app({name: 'code'})) suggested_apps.push(await get_app({name: 'editor'})) } //--------------------------------------------- // Editor //--------------------------------------------- if( fsname.endsWith('.txt') || // files with no extension !fsname.includes('.') ){ suggested_apps.push(await get_app({name: 'editor'})) suggested_apps.push(await get_app({name: 'code'})) } //--------------------------------------------- // Markus //--------------------------------------------- if(fsname.endsWith('.md')){ suggested_apps.push(await get_app({name: 'markus'})) } //--------------------------------------------- // Viewer //--------------------------------------------- if( fsname.endsWith('.jpg') || fsname.endsWith('.png') || fsname.endsWith('.webp') || fsname.endsWith('.svg') || fsname.endsWith('.bmp') || fsname.endsWith('.jpeg') ){ suggested_apps.push(await get_app({name: 'viewer'})); } //--------------------------------------------- // Draw //--------------------------------------------- if( fsname.endsWith('.bmp') || content_type.startsWith('image/') ){ suggested_apps.push(await get_app({name: 'draw'})); } //--------------------------------------------- // PDF //--------------------------------------------- if(fsname.endsWith('.pdf')){ suggested_apps.push(await get_app({name: 'pdf'})); } //--------------------------------------------- // Player //--------------------------------------------- if( fsname.endsWith('.mp4') || fsname.endsWith('.webm') || fsname.endsWith('.mpg') || fsname.endsWith('.mpv') || fsname.endsWith('.mp3') || fsname.endsWith('.m4a') || fsname.endsWith('.ogg') ){ suggested_apps.push(await get_app({name: 'player'})); } //--------------------------------------------- // 3rd-party apps //--------------------------------------------- const apps = kv.get(`assocs:${file_extension.slice(1)}:apps`) monitor.label("third party associations"); if(apps && apps.length > 0){ for (let index = 0; index < apps.length; index++) { // retrieve app from DB const third_party_app = await get_app({id: apps[index]}) if ( ! third_party_app ) continue; // only add if the app is approved for opening items or the app is owned by this user if( third_party_app.approved_for_opening_items || (options !== undefined && options.user !== undefined && options.user.id === third_party_app.owner_user_id)) suggested_apps.push(third_party_app) } } monitor.stamp(); monitor.end(); // return list return suggested_apps; } function build_item_object(item){ } async function get_taskbar_items(user) { /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_WRITE, 'filesystem'); let taskbar_items_from_db = []; // If taskbar items don't exist (specifically NULL) // add default apps. if(!user.taskbar_items){ taskbar_items_from_db = [ {name: 'editor', type: 'app'}, {name: 'dev-center', type: 'app'}, {name: 'draw', type: 'app'}, {name: 'code', type: 'app'}, {name: 'camera', type: 'app'}, {name: 'recorder', type: 'app'}, {name: 'terminal', type: 'app'}, {name: 'about', type: 'app'}, ]; await db.write( `UPDATE user SET taskbar_items = ? WHERE id = ?`, [ JSON.stringify(taskbar_items_from_db), user.id, ] ); invalidate_cached_user(user); } // there are items from before else{ try { taskbar_items_from_db = JSON.parse(user.taskbar_items); }catch(e){ // ignore errors } } // get apps that these taskbar items represent let taskbar_items = []; for (let index = 0; index < taskbar_items_from_db.length; index++) { const taskbar_item_from_db = taskbar_items_from_db[index]; if(taskbar_item_from_db.type === 'app' && taskbar_item_from_db.name !== 'explorer'){ let item = {}; if(taskbar_item_from_db.name) item = await get_app({name: taskbar_item_from_db.name}); else if(taskbar_item_from_db.id) item = await get_app({id: taskbar_item_from_db.id}); else if(taskbar_item_from_db.uid) item = await get_app({uid: taskbar_item_from_db.uid}); // if item not found, skip it if(!item) continue; // delete sensitive attributes delete item.id; delete item.owner_user_id; delete item.timestamp; // delete item.godmode; delete item.approved_for_listing; delete item.approved_for_opening_items; // add to final object taskbar_items.push(item) } } return taskbar_items; } function validate_signature_auth(url, action) { const query = new URL(url).searchParams; if(!query.get('uid')) throw {message: '`uid` is required for signature-based authentication.'} else if(!action) throw {message: '`action` is required for signature-based authentication.'} else if(!query.get('expires')) throw {message: '`expires` is required for signature-based authentication.'} else if(!query.get('signature')) throw {message: '`signature` is required for signature-based authentication.'} const expired = query.get('expires') && (query.get('expires') < Date.now() / 1000); // expired? if(expired) throw {message: 'Authentication failed. Signature expired.'} const uid = query.get('uid'); const secret = config.url_signature_secret; const sha256 = require('js-sha256').sha256; // before doing anything, see if this signature is valid for 'write' action, if yes that means every action is allowed if(!expired && query.get('signature') === sha256(`${uid}/write/${secret}/${query.get('expires')}`)) return true; // if not, check specific actions else if(!expired && query.get('signature') === sha256(`${uid}/${action}/${secret}/${query.get('expires')}`)) return true; // auth failed else throw {message: 'Authentication failed'} } function get_url_from_req(req) { return req.protocol + '://' + req.get('host') + req.originalUrl; } async function mv(options){ throw new Error('legacy mv function called'); } /** * Formats a number with grouped thousands. * * @param {number|string} number - The number to be formatted. If a string is provided, it must only contain numerical characters, plus and minus signs, and the letter 'E' or 'e' (for scientific notation). * @param {number} decimals - The number of decimal points. If a non-finite number is provided, it defaults to 0. * @param {string} [dec_point='.'] - The character used for the decimal point. Defaults to '.' if not provided. * @param {string} [thousands_sep=','] - The character used for the thousands separator. Defaults to ',' if not provided. * @returns {string} The formatted number with grouped thousands, using the specified decimal point and thousands separator characters. * @throws {TypeError} If the `number` parameter cannot be converted to a finite number, or if the `decimals` parameter is non-finite and cannot be converted to an absolute number. */ function number_format (number, decimals, dec_point, thousands_sep) { // Strip all characters but numerical ones. number = (number + '').replace(/[^0-9+\-Ee.]/g, ''); var n = !isFinite(+number) ? 0 : +number, prec = !isFinite(+decimals) ? 0 : Math.abs(decimals), sep = (typeof thousands_sep === 'undefined') ? ',' : thousands_sep, dec = (typeof dec_point === 'undefined') ? '.' : dec_point, s = '', toFixedFix = function (n, prec) { var k = Math.pow(10, prec); return '' + Math.round(n * k) / k; }; // Fix for IE parseFloat(0.55).toFixed(0) = 0; s = (prec ? toFixedFix(n, prec) : '' + Math.round(n)).split('.'); if (s[0].length > 3) { s[0] = s[0].replace(/\B(?=(?:\d{3})+(?!\d))/g, sep); } if ((s[1] || '').length < prec) { s[1] = s[1] || ''; s[1] += new Array(prec - s[1].length + 1).join('0'); } return s.join(dec); } module.exports = { ancestors, app_name_exists, app_exists, body_parser_error_handler, build_item_object, byte_format, change_username, chkperm, convert_path_to_fsentry, cp, deleteUser, get_descendants, get_dir_size, gen_public_token, get_taskbar_items, get_url_from_req, generate_system_fsentries, generate_random_str, generate_random_username, get_app, get_user, invalidate_cached_user, invalidate_cached_user_by_id, has_shared_with, hyphenize_confirm_code, id2fsentry, id2path, id2uuid, is_ancestor_of, is_empty, is_shared_with, is_shared_with_anyone, is_valid_uuid4, is_valid_uuid, is_specifically_uuidv4, is_valid_url, jwt_auth, mkdir, mv, number_format, refresh_apps_cache, refresh_associations_cache, resolve_glob, rm, seconds_to_string, send_email_verification_code, send_email_verification_token, sign_file, subdomain, suggest_app_for_fsentry, df, username_exists, uuid2fsentry, validate_fsentry_name, validate_signature_auth, tmp_provide_services, };