mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-07 04:12:36 +00:00
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled
* decrease global check alerts * fix: don't stream back bytes for icons
2079 lines
66 KiB
JavaScript
2079 lines
66 KiB
JavaScript
/*
|
|
* Copyright (C) 2024-present 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 _path = require('path');
|
|
const micromatch = require('micromatch');
|
|
const config = require('./config');
|
|
const mime = require('mime-types');
|
|
const { LRUCache } = require('lru-cache');
|
|
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 { Context } = require('./util/context');
|
|
const { NodeUIDSelector } = require('./filesystem/node/selectors');
|
|
const { redisClient } = require('./clients/redis/redisSingleton');
|
|
const { kv } = require('./util/kvSingleton');
|
|
const { APP_ICONS_SUBDOMAIN } = require('./consts/app-icons.js');
|
|
|
|
const identifying_uuid = require('uuid').v4();
|
|
|
|
// Use global singleton for services to handle ESM/CJS dual-loading in vitest
|
|
const SERVICES_KEY = Symbol.for('puter.helpers.services');
|
|
globalThis[SERVICES_KEY] = globalThis[SERVICES_KEY] ?? { services: null };
|
|
const _servicesHolder = globalThis[SERVICES_KEY];
|
|
|
|
const tmp_provide_services = async ss => {
|
|
_servicesHolder.services = ss;
|
|
await _servicesHolder.services.ready;
|
|
};
|
|
|
|
// TTL for pending get_app queries (request coalescing)
|
|
const PENDING_QUERY_TTL = 10; // seconds
|
|
const SUGGESTED_APPS_CACHE_MAX = 10000;
|
|
const suggestedAppsCache = new LRUCache({ max: SUGGESTED_APPS_CACHE_MAX });
|
|
const DEFAULT_APP_ICON_SIZE = 256;
|
|
|
|
const safe_json_parse = (value, fallback) => {
|
|
if ( value === null || value === undefined ) return fallback;
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch ( error ) {
|
|
return fallback;
|
|
}
|
|
};
|
|
|
|
const buildAppIconUrl = (app_uid, size = DEFAULT_APP_ICON_SIZE) => {
|
|
if ( ! app_uid ) return null;
|
|
const uid_string = String(app_uid);
|
|
const normalized_uid = uid_string.startsWith('app-') ? uid_string : `app-${uid_string}`;
|
|
const icon_size = Number.isFinite(Number(size)) ? Number(size) : DEFAULT_APP_ICON_SIZE;
|
|
const static_hosting_domain = config.static_hosting_domain || config.static_hosting_domain_alt;
|
|
if ( ! static_hosting_domain ) return null;
|
|
const protocol = config.protocol || 'https';
|
|
return `${protocol}://${APP_ICONS_SUBDOMAIN}.${static_hosting_domain}/${normalized_uid}-${icon_size}.png`;
|
|
};
|
|
|
|
const withAppIconUrl = (app) => {
|
|
if ( ! app ) return app;
|
|
const icon_url = buildAppIconUrl(app.uid ?? app.uuid);
|
|
if ( ! icon_url ) return { ...app };
|
|
return { ...app, icon: icon_url };
|
|
};
|
|
|
|
async function is_empty (dir_uuid) {
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
|
|
|
|
let rows;
|
|
|
|
if ( typeof dir_uuid === 'object' ) {
|
|
if ( typeof dir_uuid.path === 'string' && dir_uuid.path !== '' ) {
|
|
rows = await db.read(`SELECT EXISTS(SELECT 1 FROM fsentries WHERE path LIKE ${db.case({
|
|
sqlite: '? || \'%\'',
|
|
otherwise: 'CONCAT(?, \'%\')',
|
|
})} LIMIT 1) AS not_empty`,
|
|
[`${dir_uuid.path }/`]);
|
|
} else dir_uuid = dir_uuid.uid;
|
|
}
|
|
|
|
if ( typeof dir_uuid === 'string' ) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Checks to see if temp_users is disabled and return a boolean
|
|
* @returns {boolean}
|
|
*/
|
|
async function is_temp_users_disabled () {
|
|
const svc_feature_flag = await _servicesHolder.services.get('feature-flag');
|
|
return await svc_feature_flag.check('temp-users-disabled');
|
|
}
|
|
|
|
/**
|
|
* Checks to see if user_signup is disabled and return a boolean
|
|
* @returns {boolean}
|
|
*/
|
|
async function is_user_signup_disabled () {
|
|
const svc_feature_flag = await _servicesHolder.services.get('feature-flag');
|
|
return await svc_feature_flag.check('user-signup-disabled');
|
|
}
|
|
|
|
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;
|
|
}
|
|
// 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 && 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 = _servicesHolder.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 = _servicesHolder.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) {
|
|
return await _servicesHolder.services.get('get-user').get_user(options);
|
|
}
|
|
|
|
/**
|
|
* Invalidate the cached entries for a user object
|
|
*
|
|
* @param {User} userID - the user entry to invalidate
|
|
*/
|
|
const invalidate_cached_user = async (user) => {
|
|
await Promise.all([
|
|
redisClient.del(`users:username:${ user.username}`),
|
|
redisClient.del(`users:uuid:${ user.uuid}`),
|
|
redisClient.del(`users:email:${ user.email}`),
|
|
redisClient.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
|
|
*/
|
|
const invalidate_cached_user_by_id = async (id) => {
|
|
const user = safe_json_parse(await redisClient.get(`users:id:${ id}`), null);
|
|
if ( ! user ) return;
|
|
invalidate_cached_user(user);
|
|
};
|
|
|
|
async function refresh_associations_cache () {
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.services.get('database').get(DB_READ, 'apps');
|
|
console.debug('refresh file associations');
|
|
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);
|
|
// Default file association entries were added with empty types;
|
|
// this prevents those from showing up.
|
|
if ( ext === '' ) continue;
|
|
if ( ! Object.prototype.hasOwnProperty.call(lists, ext) ) lists[ext] = [];
|
|
lists[ext].push(association.app_id);
|
|
}
|
|
|
|
for ( const k in lists ) {
|
|
await redisClient.set(`assocs:${k}:apps`, JSON.stringify(lists[k]));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get App by a variety of IDs
|
|
*
|
|
* @param {string} options - `options`
|
|
* @returns {Promise}
|
|
*/
|
|
async function get_app (options) {
|
|
|
|
const cacheApp = async (app) => {
|
|
if ( ! app ) return;
|
|
app = JSON.stringify(app);
|
|
await redisClient.set(`apps:uid:${app.uid}`, app, 'EX', 30);
|
|
await redisClient.set(`apps:name:${app.name}`, app, 'EX', 30);
|
|
await redisClient.set(`apps:id:${app.id}`, app, 'EX', 30);
|
|
};
|
|
|
|
// This condition should be updated if the code below is re-ordered.
|
|
if ( options.follow_old_names && !options.uid && options.name ) {
|
|
const svc_oldAppName = _servicesHolder.services.get('old-app-name');
|
|
const old_name = await svc_oldAppName.check_app_name(options.name);
|
|
if ( old_name ) {
|
|
options.uid = old_name.app_uid;
|
|
|
|
// The following line is technically pointless, but may avoid a bug
|
|
// if the if...else chain below is re-ordered.
|
|
delete options.name;
|
|
}
|
|
}
|
|
|
|
// Determine the query key for request coalescing
|
|
let queryKey;
|
|
let cacheKey;
|
|
if ( options.uid ) {
|
|
queryKey = `uid:${options.uid}`;
|
|
cacheKey = `apps:uid:${options.uid}`;
|
|
} else if ( options.name ) {
|
|
queryKey = `name:${options.name}`;
|
|
cacheKey = `apps:name:${options.name}`;
|
|
} else if ( options.id ) {
|
|
queryKey = `id:${options.id}`;
|
|
cacheKey = `apps:id:${options.id}`;
|
|
} else {
|
|
// No valid lookup parameter
|
|
return null;
|
|
}
|
|
|
|
// Check cache first
|
|
let app = safe_json_parse(await redisClient.get(cacheKey), null);
|
|
if ( app ) {
|
|
// shallow clone because we use the `delete` operator
|
|
// and it corrupts the cache otherwise
|
|
return { ...app };
|
|
}
|
|
|
|
// Check if there's already a pending query for this key (request coalescing)
|
|
const pendingKey = `pending_app:${queryKey}`;
|
|
const pending = kv.get(pendingKey);
|
|
if ( pending ) {
|
|
// Reuse the existing pending query
|
|
const result = await pending;
|
|
// shallow clone the result
|
|
return result ? { ...result } : null;
|
|
}
|
|
|
|
// Create a new pending query
|
|
let resolveQuery;
|
|
let rejectQuery;
|
|
const queryPromise = new Promise((resolve, reject) => {
|
|
resolveQuery = resolve;
|
|
rejectQuery = reject;
|
|
});
|
|
|
|
kv.set(pendingKey, queryPromise, { 'EX': PENDING_QUERY_TTL });
|
|
|
|
try {
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.services.get('database').get(DB_READ, 'apps');
|
|
|
|
if ( options.uid ) {
|
|
app = (await db.read('SELECT * FROM `apps` WHERE `uid` = ? LIMIT 1', [options.uid]))[0];
|
|
} else if ( options.name ) {
|
|
app = (await db.read('SELECT * FROM `apps` WHERE `name` = ? LIMIT 1', [options.name]))[0];
|
|
} else if ( options.id ) {
|
|
app = (await db.read('SELECT * FROM `apps` WHERE `id` = ? LIMIT 1', [options.id]))[0];
|
|
}
|
|
|
|
cacheApp(app);
|
|
resolveQuery(app);
|
|
} catch ( err ) {
|
|
rejectQuery(err);
|
|
throw err;
|
|
} finally {
|
|
// Clean up the pending query after completion
|
|
kv.del(pendingKey);
|
|
}
|
|
|
|
if ( ! app ) return null;
|
|
// shallow clone because we use the `delete` operator
|
|
// and it corrupts the cache otherwise
|
|
app = { ...app };
|
|
|
|
return app;
|
|
}
|
|
|
|
/**
|
|
* Get multiple apps by uid/name/id, aligned to the input order.
|
|
*
|
|
* @param {Array<{uid?: string, name?: string, id?: string|number}>} specifiers
|
|
* @param {Object} [options]
|
|
* @param {boolean} [options.rawIcon] - When true, include raw icon data.
|
|
* @returns {Promise<Array<object|null>>}
|
|
*/
|
|
const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
|
|
if ( ! Array.isArray(specifiers) ) {
|
|
specifiers = [specifiers];
|
|
}
|
|
|
|
const rawIcon = Boolean(options.rawIcon);
|
|
const cacheNamespace = rawIcon ? 'apps' : 'apps:lite';
|
|
const pendingNamespace = rawIcon ? 'pending_app' : 'pending_app_lite';
|
|
const decorateApp = (app) => (rawIcon ? app : withAppIconUrl(app));
|
|
const cacheApp = async (app) => {
|
|
if ( ! app ) return;
|
|
const cached_app = JSON.stringify(app);
|
|
await redisClient.set(`${cacheNamespace}:uid:${cached_app.uid}`, cached_app, 'EX', 60);
|
|
await redisClient.set(`${cacheNamespace}:name:${cached_app.name}`, cached_app, 'EX', 60);
|
|
await redisClient.set(`${cacheNamespace}:id:${cached_app.id}`, cached_app, 'EX', 60);
|
|
};
|
|
|
|
const APP_COLUMNS_NO_ICON = [
|
|
'id',
|
|
'uid',
|
|
'owner_user_id',
|
|
'name',
|
|
'title',
|
|
'description',
|
|
'godmode',
|
|
'maximize_on_start',
|
|
'index_url',
|
|
'approved_for_listing',
|
|
'approved_for_opening_items',
|
|
'approved_for_incentive_program',
|
|
'timestamp',
|
|
'last_review',
|
|
'tags',
|
|
'app_owner',
|
|
'metadata',
|
|
'protected',
|
|
'background',
|
|
].map(column => `\`${column}\``).join(', ');
|
|
|
|
const normalized = specifiers.map(spec => spec ? { ...spec } : {});
|
|
|
|
if ( options.follow_old_names ) {
|
|
const svc_oldAppName = _servicesHolder.services.get('old-app-name');
|
|
for ( const spec of normalized ) {
|
|
if ( spec.uid || !spec.name ) continue;
|
|
const old_name = await svc_oldAppName.check_app_name(spec.name);
|
|
if ( old_name ) {
|
|
spec.uid = old_name.app_uid;
|
|
delete spec.name;
|
|
}
|
|
}
|
|
}
|
|
|
|
const appByUid = new Map();
|
|
const appByName = new Map();
|
|
const appById = new Map();
|
|
|
|
const addApp = (app) => {
|
|
if ( ! app ) return;
|
|
appByUid.set(app.uid, app);
|
|
appByName.set(app.name, app);
|
|
appById.set(app.id, app);
|
|
};
|
|
|
|
const pendingLookups = new Map();
|
|
const pendingToResolve = new Map();
|
|
const queryUids = new Set();
|
|
const queryNames = new Set();
|
|
const queryIds = new Set();
|
|
|
|
const queueMissing = (type, value) => {
|
|
const queryKey = `${type}:${value}`;
|
|
if ( pendingToResolve.has(queryKey) || pendingLookups.has(queryKey) ) {
|
|
return;
|
|
}
|
|
|
|
const pendingKey = `${pendingNamespace}:${queryKey}`;
|
|
const pending = kv.get(pendingKey);
|
|
if ( pending ) {
|
|
pendingLookups.set(queryKey, pending);
|
|
return;
|
|
}
|
|
|
|
let resolveQuery;
|
|
let rejectQuery;
|
|
const queryPromise = new Promise((resolve, reject) => {
|
|
resolveQuery = resolve;
|
|
rejectQuery = reject;
|
|
});
|
|
kv.set(pendingKey, queryPromise, { 'EX': PENDING_QUERY_TTL });
|
|
pendingToResolve.set(queryKey, { resolveQuery, rejectQuery, pendingKey });
|
|
|
|
if ( type === 'uid' ) {
|
|
queryUids.add(value);
|
|
} else if ( type === 'name' ) {
|
|
queryNames.add(value);
|
|
} else if ( type === 'id' ) {
|
|
queryIds.add(value);
|
|
}
|
|
};
|
|
|
|
for ( const spec of normalized ) {
|
|
if ( spec.uid ) {
|
|
const cached = safe_json_parse(await redisClient.get(`${cacheNamespace}:uid:${spec.uid}`), null);
|
|
if ( cached ) {
|
|
addApp(decorateApp(cached));
|
|
} else {
|
|
queueMissing('uid', spec.uid);
|
|
}
|
|
continue;
|
|
}
|
|
if ( spec.name ) {
|
|
const cached = safe_json_parse(await redisClient.get(`${cacheNamespace}:name:${spec.name}`), null);
|
|
if ( cached ) {
|
|
addApp(decorateApp(cached));
|
|
} else {
|
|
queueMissing('name', spec.name);
|
|
}
|
|
continue;
|
|
}
|
|
if ( spec.id ) {
|
|
const cached = safe_json_parse(await redisClient.get(`${cacheNamespace}:id:${spec.id}`), null);
|
|
if ( cached ) {
|
|
addApp(decorateApp(cached));
|
|
} else {
|
|
queueMissing('id', spec.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
const pendingResultsPromise = pendingLookups.size
|
|
? Promise.all(Array.from(pendingLookups.values()))
|
|
: Promise.resolve([]);
|
|
|
|
if ( queryUids.size || queryNames.size || queryIds.size ) {
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.services.get('database').get(DB_READ, 'apps');
|
|
|
|
const clauses = [];
|
|
const params = [];
|
|
|
|
if ( queryUids.size ) {
|
|
const uids = Array.from(queryUids);
|
|
clauses.push(`uid IN (${uids.map(() => '?').join(', ')})`);
|
|
params.push(...uids);
|
|
}
|
|
if ( queryNames.size ) {
|
|
const names = Array.from(queryNames);
|
|
clauses.push(`name IN (${names.map(() => '?').join(', ')})`);
|
|
params.push(...names);
|
|
}
|
|
if ( queryIds.size ) {
|
|
const ids = Array.from(queryIds);
|
|
clauses.push(`id IN (${ids.map(() => '?').join(', ')})`);
|
|
params.push(...ids);
|
|
}
|
|
|
|
let rows = [];
|
|
const resolvedKeys = new Set();
|
|
try {
|
|
const select_columns = rawIcon ? '*' : APP_COLUMNS_NO_ICON;
|
|
rows = await db.read(`SELECT ${select_columns} FROM \`apps\` WHERE ${clauses.join(' OR ')}`,
|
|
params);
|
|
for ( const app of rows ) {
|
|
const decorated_app = decorateApp(app);
|
|
cacheApp(decorated_app);
|
|
addApp(decorated_app);
|
|
|
|
const uidKey = `uid:${decorated_app.uid}`;
|
|
const nameKey = `name:${decorated_app.name}`;
|
|
const idKey = `id:${decorated_app.id}`;
|
|
|
|
if ( pendingToResolve.has(uidKey) ) {
|
|
pendingToResolve.get(uidKey).resolveQuery(decorated_app);
|
|
resolvedKeys.add(uidKey);
|
|
}
|
|
if ( pendingToResolve.has(nameKey) ) {
|
|
pendingToResolve.get(nameKey).resolveQuery(decorated_app);
|
|
resolvedKeys.add(nameKey);
|
|
}
|
|
if ( pendingToResolve.has(idKey) ) {
|
|
pendingToResolve.get(idKey).resolveQuery(decorated_app);
|
|
resolvedKeys.add(idKey);
|
|
}
|
|
}
|
|
|
|
for ( const [key, { resolveQuery }] of pendingToResolve.entries() ) {
|
|
if ( ! resolvedKeys.has(key) ) {
|
|
resolveQuery(null);
|
|
}
|
|
}
|
|
} catch ( err ) {
|
|
for ( const { rejectQuery } of pendingToResolve.values() ) {
|
|
rejectQuery(err);
|
|
}
|
|
throw err;
|
|
} finally {
|
|
for ( const { pendingKey } of pendingToResolve.values() ) {
|
|
await redisClient.del(pendingKey);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
const pendingResults = await pendingResultsPromise;
|
|
for ( const app of pendingResults ) {
|
|
addApp(decorateApp(app));
|
|
}
|
|
|
|
return normalized.map(spec => {
|
|
let app;
|
|
if ( spec.uid ) {
|
|
app = appByUid.get(spec.uid);
|
|
} else if ( spec.name ) {
|
|
app = appByName.get(spec.name);
|
|
} else if ( spec.id ) {
|
|
app = appById.get(spec.id);
|
|
}
|
|
return app ? { ...app } : null;
|
|
});
|
|
|
|
});
|
|
|
|
/**
|
|
* Checks to see if an app exists
|
|
*
|
|
* @param {string} options - `options`
|
|
* @returns {Promise}
|
|
*/
|
|
async function app_exists (options) {
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.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 = _servicesHolder.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` = ?, `path` = ? ' +
|
|
'WHERE `user_id` = ? AND parent_uid IS NULL LIMIT 1',
|
|
[new_username, `/${ new_username}`, user_id]);
|
|
|
|
console.log(`User ${old_username} changed username to ${new_username}`);
|
|
await _servicesHolder.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 = _servicesHolder.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 = _servicesHolder.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 = _servicesHolder.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_descendants = spanify('get_descendants', async (...args) => {
|
|
return await getDescendantsHelper(...args);
|
|
});
|
|
|
|
/**
|
|
*
|
|
* @param {integer} entry_id
|
|
* @returns
|
|
*/
|
|
const id2path = spanify('helpers:id2path', async (entry_uid) => {
|
|
if ( entry_uid == null ) {
|
|
throw new Error('got null or undefined entry id');
|
|
}
|
|
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
|
|
|
|
const log = _servicesHolder.services.get('log-service').create('helpers.id2path');
|
|
log.traceOn();
|
|
const errors = _servicesHolder.services.get('error-service').create(log);
|
|
log.called();
|
|
|
|
let result;
|
|
|
|
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}`;
|
|
});
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
async function getDescendantsHelper (path, user, depth, return_thumbnail = false) {
|
|
const log = _servicesHolder.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 = _servicesHolder.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; i < sharing_users.length; i++ ) {
|
|
let dir = {};
|
|
dir.id = null;
|
|
dir.uuid = null;
|
|
dir.parent_uid = null;
|
|
dir.name = sharing_users[i].username;
|
|
dir.is_dir = true;
|
|
dir.immutable = true;
|
|
children.push(dir);
|
|
}
|
|
}
|
|
}
|
|
// -------------------------------------
|
|
// parent doesn't exist
|
|
// -------------------------------------
|
|
else if ( parent === false ) {
|
|
return [];
|
|
}
|
|
// -------------------------------------
|
|
// Parent is a shared-user directory: /[some_username](/)
|
|
// but make sure `[some_username]` is not the same as the requester's username
|
|
// -------------------------------------
|
|
else if ( username && username !== user.username ) {
|
|
children = [];
|
|
let sharing_user;
|
|
sharing_user = await get_user({ username: username });
|
|
if ( ! sharing_user )
|
|
{
|
|
return [];
|
|
}
|
|
|
|
// shared files/dirs with this user
|
|
const shared_fsentries = await db.read(`SELECT
|
|
fsentries.id, fsentries.user_id, fsentries.uuid, fsentries.parent_uid, fsentries.bucket, fsentries.bucket_region,
|
|
fsentries.name, fsentries.shortcut_to, fsentries.is_shortcut, fsentries.metadata, fsentries.is_dir, fsentries.modified,
|
|
fsentries.created, fsentries.accessed, fsentries.size, fsentries.sort_by, fsentries.associated_app_id,
|
|
fsentries.is_symlink, fsentries.symlink_path,
|
|
fsentries.immutable ${return_thumbnail ? ', fsentries.thumbnail' : ''}
|
|
FROM share
|
|
INNER JOIN fsentries ON fsentries.id = share.fsentry_id
|
|
WHERE share.recipient_user_id = ? AND owner_user_id = ?`,
|
|
[user.id, sharing_user.id]);
|
|
// merge `children` and `shared_fsentries`
|
|
if ( shared_fsentries.length > 0 ) {
|
|
for ( let i = 0; i < shared_fsentries.length; i++ ) {
|
|
shared_fsentries[i].path = await id2path(shared_fsentries[i].id);
|
|
children.push(shared_fsentries[i]);
|
|
}
|
|
}
|
|
}
|
|
// -------------------------------------
|
|
// All other cases
|
|
// -------------------------------------
|
|
else {
|
|
children = [];
|
|
let temp_children = await db.read(`SELECT
|
|
id, user_id, uuid, parent_uid, name, metadata, is_shortcut,
|
|
shortcut_to, is_dir, modified, created, accessed, size, sort_by, associated_app_id,
|
|
is_symlink, symlink_path,
|
|
immutable ${return_thumbnail ? ', thumbnail' : ''}
|
|
FROM fsentries
|
|
WHERE parent_uid = ?`,
|
|
[parent.uuid]);
|
|
// check if user has access to each file, if yes add it
|
|
if ( temp_children.length > 0 ) {
|
|
for ( let i = 0; i < temp_children.length; i++ ) {
|
|
const tchild = temp_children[i];
|
|
if ( await chkperm(tchild, user.id) )
|
|
{
|
|
children.push(tchild);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// shortcut on empty result set
|
|
if ( children.length === 0 ) return [];
|
|
|
|
const ids = children.map(child => 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]);
|
|
|
|
const websiteMap = {};
|
|
for ( const row of rows ) websiteMap[row.root_dir_id] = true;
|
|
|
|
for ( let i = 0; i < children.length; i++ ) {
|
|
const contentType = mime.contentType(children[i].name);
|
|
|
|
// has_website
|
|
let has_website = false;
|
|
if ( children[i].is_dir ) {
|
|
has_website = websiteMap[children[i].id];
|
|
}
|
|
|
|
// object to return
|
|
// TODO: DRY creation of response fsentry from db fsentry
|
|
ret.push({
|
|
path: children[i].path ?? (`${path }/${ children[i].name}`),
|
|
name: children[i].name,
|
|
metadata: children[i].metadata,
|
|
_id: children[i].id,
|
|
id: children[i].uuid,
|
|
uid: children[i].uuid,
|
|
is_shortcut: children[i].is_shortcut,
|
|
shortcut_to: (children[i].shortcut_to ? await id2uuid(children[i].shortcut_to) : undefined),
|
|
shortcut_to_path: (children[i].shortcut_to ? await id2path(children[i].shortcut_to) : undefined),
|
|
is_symlink: children[i].is_symlink,
|
|
symlink_path: children[i].symlink_path,
|
|
immutable: children[i].immutable,
|
|
is_dir: children[i].is_dir,
|
|
modified: children[i].modified,
|
|
created: children[i].created,
|
|
accessed: children[i].accessed,
|
|
size: children[i].size,
|
|
sort_by: children[i].sort_by,
|
|
thumbnail: children[i].thumbnail,
|
|
associated_app_id: children[i].associated_app_id,
|
|
type: contentType ? contentType : null,
|
|
has_website: has_website,
|
|
});
|
|
if ( children[i].is_dir &&
|
|
(depth === undefined || (depth !== undefined && depth > 0))
|
|
) {
|
|
ret.push(await get_descendants(`${path }/${ children[i].name}`, user, depth));
|
|
}
|
|
}
|
|
return ret.flat();
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
/**
|
|
*
|
|
* @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);
|
|
});
|
|
}
|
|
|
|
function isString (variable) {
|
|
return typeof variable === 'string' || variable instanceof String;
|
|
}
|
|
|
|
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();
|
|
};
|
|
|
|
/**
|
|
* Given a uid, returns a file node.
|
|
*
|
|
* TODO (xiaochen): It only works for MemoryFSProvider currently.
|
|
*
|
|
* @param {string} uid - The uid of the file to get.
|
|
* @returns {Promise<MemoryFile|null>} The file node, or null if the file does not exist.
|
|
*/
|
|
async function get_entry (uid) {
|
|
const svc_mountpoint = Context.get('services').get('mountpoint');
|
|
const uid_selector = new NodeUIDSelector(uid);
|
|
const provider = await svc_mountpoint.get_provider(uid_selector);
|
|
|
|
// NB: We cannot import MemoryFSProvider here because it will cause a circular dependency.
|
|
if ( provider.constructor.name !== 'MemoryFSProvider' ) {
|
|
return null;
|
|
}
|
|
|
|
return provider.stat({
|
|
selector: uid_selector,
|
|
});
|
|
}
|
|
|
|
async function is_ancestor_of (ancestor_uid, descendant_uid) {
|
|
const ancestor = await get_entry(ancestor_uid);
|
|
const descendant = await get_entry(descendant_uid);
|
|
|
|
if ( ancestor && descendant ) {
|
|
return descendant.path.startsWith(ancestor.path);
|
|
}
|
|
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.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) {
|
|
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 token = uuidv4();
|
|
const contentType = mime.contentType(fsentry.name);
|
|
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.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) {
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
|
|
const svc_fs = _servicesHolder.services.get('filesystem');
|
|
|
|
// get a list of up to 5000 files owned by this user
|
|
// eslint-disable-next-line no-constant-condition
|
|
for ( let offset = 0; true; offset += 5000 ) {
|
|
let files = await db.read(`SELECT uuid, bucket, bucket_region FROM fsentries WHERE user_id = ? AND is_dir = 0 LIMIT 5000 OFFSET ${ offset}`,
|
|
[user_id]);
|
|
|
|
if ( !files || files.length == 0 ) break;
|
|
|
|
// delete all files from S3
|
|
if ( files !== null && files.length > 0 ) {
|
|
for ( let i = 0; i < files.length; i++ ) {
|
|
const node = await svc_fs.node(new NodeUIDSelector(files[i].uuid));
|
|
|
|
await node.provider.unlink({
|
|
context: Context.get(),
|
|
override_immutable: true,
|
|
node,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// delete all fsentries from DB
|
|
await db.write('DELETE FROM fsentries WHERE user_id = ?', [user_id]);
|
|
|
|
// delete user
|
|
await db.write('DELETE FROM user WHERE id = ?', [user_id]);
|
|
}
|
|
|
|
function subdomain (req) {
|
|
if ( config.experimental_no_subdomain ) return 'api';
|
|
return req.hostname.slice(0, -1 * (config.domain.length + 1));
|
|
}
|
|
|
|
async function jwt_auth (req) {
|
|
let token;
|
|
// HTTML Auth header
|
|
if ( req.header && req.header('Authorization') )
|
|
{
|
|
token = req.header('Authorization');
|
|
}
|
|
// Cookie
|
|
else if ( req.cookies && req.cookies[config.cookie_name] )
|
|
{
|
|
token = req.cookies[config.cookie_name];
|
|
}
|
|
// Auth token in URL
|
|
else if ( req.query && req.query.auth_token )
|
|
{
|
|
token = req.query.auth_token;
|
|
}
|
|
// Socket
|
|
else if ( req.handshake && req.handshake.auth && req.handshake.auth.auth_token )
|
|
{
|
|
token = req.handshake.auth.auth_token;
|
|
}
|
|
|
|
if ( !token || token === 'null' )
|
|
{
|
|
throw ('No auth token found');
|
|
}
|
|
else if ( typeof token !== 'string' )
|
|
{
|
|
throw ('token must be a string.');
|
|
}
|
|
else
|
|
{
|
|
token = token.replace('Bearer ', '');
|
|
}
|
|
|
|
try {
|
|
const svc_auth = Context.get('services').get('auth');
|
|
const actor = await svc_auth.authenticate_from_token(token);
|
|
|
|
if ( !actor.type?.constructor?.name === 'UserActorType' ) {
|
|
throw ({
|
|
message: APIError.create('token_unsupported')
|
|
.serialize(),
|
|
});
|
|
}
|
|
|
|
return {
|
|
actor,
|
|
user: actor.type.user,
|
|
token: token,
|
|
};
|
|
} catch (e) {
|
|
if ( ! (e instanceof APIError) ) {
|
|
console.log('ERROR', e);
|
|
}
|
|
throw (e.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* returns all ancestors of an fsentry
|
|
*
|
|
* @param {*} fsentry_id
|
|
*/
|
|
async function ancestors (fsentry_id) {
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
|
|
|
|
const ancestors = [];
|
|
// first parent
|
|
let parent = await db.read('SELECT * FROM `fsentries` WHERE `id` = ? LIMIT 1', [fsentry_id]);
|
|
if ( parent.length === 0 ) {
|
|
return ancestors;
|
|
}
|
|
// get all subsequent parents
|
|
while ( parent[0].parent_uid !== null ) {
|
|
const parent_fsentry = await uuid2fsentry(parent[0].parent_uid);
|
|
parent = await db.read('SELECT * FROM `fsentries` WHERE `id` = ? LIMIT 1', [parent_fsentry.id]);
|
|
if ( parent[0].length !== 0 ) {
|
|
ancestors.push(parent[0]);
|
|
}
|
|
}
|
|
|
|
return ancestors;
|
|
}
|
|
|
|
function hyphenize_confirm_code (email_confirm_code) {
|
|
email_confirm_code = email_confirm_code.toString();
|
|
email_confirm_code =
|
|
`${email_confirm_code[0] +
|
|
email_confirm_code[1] +
|
|
email_confirm_code[2]
|
|
}-${
|
|
email_confirm_code[3]
|
|
}${email_confirm_code[4]
|
|
}${email_confirm_code[5]}`;
|
|
return email_confirm_code;
|
|
}
|
|
|
|
async function username_exists (username) {
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
|
|
|
|
let rows = await db.read('SELECT EXISTS(SELECT 1 FROM user WHERE username=?) AS username_exists', [username]);
|
|
if ( rows[0].username_exists )
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
async function app_name_exists (name) {
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
|
|
|
|
let rows = await db.read('SELECT EXISTS(SELECT 1 FROM apps WHERE apps.name=?) AS app_name_exists', [name]);
|
|
if ( rows[0].app_name_exists )
|
|
{
|
|
return true;
|
|
}
|
|
|
|
const svc_oldAppName = _servicesHolder.services.get('old-app-name');
|
|
const name_info = await svc_oldAppName.check_app_name(name);
|
|
if ( name_info ) return true;
|
|
}
|
|
|
|
function send_email_verification_code (email_confirm_code, email) {
|
|
const svc_email = Context.get('services').get('email');
|
|
svc_email.send_email({ email }, 'email_verification_code', {
|
|
code: hyphenize_confirm_code(email_confirm_code),
|
|
});
|
|
}
|
|
|
|
function send_email_verification_token (email_confirm_token, email, user_uuid) {
|
|
const svc_email = Context.get('services').get('email');
|
|
const link = `${config.origin}/confirm-email-by-token?user_uuid=${user_uuid}&token=${email_confirm_token}`;
|
|
svc_email.send_email({ email }, 'email_verification_link', { link });
|
|
}
|
|
|
|
function generate_random_str (length) {
|
|
let result = '';
|
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
const charactersLength = characters.length;
|
|
for ( let i = 0; i < length; i++ ) {
|
|
result += characters.charAt(Math.floor(Math.random() *
|
|
charactersLength));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Converts a given number of seconds into a human-readable string format.
|
|
*
|
|
* @param {number} seconds - The number of seconds to be converted.
|
|
* @returns {string} The time represented in the format: 'X years Y days Z hours A minutes B seconds'.
|
|
* @throws {TypeError} If the `seconds` parameter is not a number.
|
|
*/
|
|
function seconds_to_string (seconds) {
|
|
const numyears = Math.floor(seconds / 31536000);
|
|
const numdays = Math.floor((seconds % 31536000) / 86400);
|
|
const numhours = Math.floor(((seconds % 31536000) % 86400) / 3600);
|
|
const numminutes = Math.floor((((seconds % 31536000) % 86400) % 3600) / 60);
|
|
const numseconds = (((seconds % 31536000) % 86400) % 3600) % 60;
|
|
return `${numyears } years ${ numdays } days ${ numhours } hours ${ numminutes } minutes ${ numseconds } seconds`;
|
|
}
|
|
|
|
/**
|
|
* returns a list of apps that could open the fsentry, ranked by relevance
|
|
* @param {*} fsentry
|
|
* @param {*} options
|
|
*/
|
|
const SUGGEST_APP_CODE_EXTS = [
|
|
'.asm',
|
|
'.asp',
|
|
'.aspx',
|
|
'.bash',
|
|
'.c',
|
|
'.cpp',
|
|
'.css',
|
|
'.csv',
|
|
'.dhtml',
|
|
'.f',
|
|
'.go',
|
|
'.h',
|
|
'.htm',
|
|
'.html',
|
|
'.html5',
|
|
'.java',
|
|
'.jl',
|
|
'.js',
|
|
'.jsa',
|
|
'.json',
|
|
'.jsonld',
|
|
'.jsf',
|
|
'.jsp',
|
|
'.kt',
|
|
'.log',
|
|
'.lock',
|
|
'.lua',
|
|
'.md',
|
|
'.perl',
|
|
'.phar',
|
|
'.php',
|
|
'.pl',
|
|
'.py',
|
|
'.r',
|
|
'.rb',
|
|
'.rdata',
|
|
'.rda',
|
|
'.rdf',
|
|
'.rds',
|
|
'.rs',
|
|
'.rlib',
|
|
'.rpy',
|
|
'.scala',
|
|
'.sc',
|
|
'.scm',
|
|
'.sh',
|
|
'.sol',
|
|
'.sql',
|
|
'.ss',
|
|
'.svg',
|
|
'.swift',
|
|
'.toml',
|
|
'.ts',
|
|
'.wasm',
|
|
'.xhtml',
|
|
'.xml',
|
|
'.yaml',
|
|
];
|
|
|
|
const buildSuggestedAppSpecifiers = async (fsentry) => {
|
|
const name_specifiers = [];
|
|
|
|
let content_type = mime.contentType(fsentry.name);
|
|
if ( ! content_type ) content_type = '';
|
|
|
|
// IIFE just so fsname can stay `const`
|
|
const fsname = (() => {
|
|
if ( ! fsentry.name ) {
|
|
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();
|
|
|
|
const any_of = (list, name) => list.some(v => name.endsWith(v));
|
|
|
|
//---------------------------------------------
|
|
// Code
|
|
//---------------------------------------------
|
|
if ( any_of(SUGGEST_APP_CODE_EXTS, fsname) || !fsname.includes('.') ) {
|
|
name_specifiers.push({ name: 'code' });
|
|
name_specifiers.push({ name: 'editor' });
|
|
}
|
|
|
|
//---------------------------------------------
|
|
// Editor
|
|
//---------------------------------------------
|
|
if (
|
|
fsname.endsWith('.txt') ||
|
|
// files with no extension
|
|
!fsname.includes('.')
|
|
) {
|
|
name_specifiers.push({ name: 'editor' });
|
|
name_specifiers.push({ name: 'code' });
|
|
}
|
|
//---------------------------------------------
|
|
// Markus
|
|
//---------------------------------------------
|
|
if ( fsname.endsWith('.md') ) {
|
|
name_specifiers.push({ name: 'markus' });
|
|
}
|
|
//---------------------------------------------
|
|
// Viewer
|
|
//---------------------------------------------
|
|
if (
|
|
fsname.endsWith('.jpg') ||
|
|
fsname.endsWith('.png') ||
|
|
fsname.endsWith('.webp') ||
|
|
fsname.endsWith('.svg') ||
|
|
fsname.endsWith('.bmp') ||
|
|
fsname.endsWith('.jpeg')
|
|
) {
|
|
name_specifiers.push({ name: 'viewer' });
|
|
}
|
|
//---------------------------------------------
|
|
// Draw
|
|
//---------------------------------------------
|
|
if (
|
|
fsname.endsWith('.bmp') ||
|
|
content_type.startsWith('image/')
|
|
) {
|
|
name_specifiers.push({ name: 'draw' });
|
|
}
|
|
//---------------------------------------------
|
|
// PDF
|
|
//---------------------------------------------
|
|
if ( fsname.endsWith('.pdf') ) {
|
|
name_specifiers.push({ 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')
|
|
) {
|
|
name_specifiers.push({ name: 'player' });
|
|
}
|
|
|
|
//---------------------------------------------
|
|
// 3rd-party apps
|
|
//---------------------------------------------
|
|
const apps = safe_json_parse(await redisClient.get(`assocs:${file_extension.slice(1)}:apps`), []);
|
|
/** @type {{id:string}[]} */
|
|
const id_specifiers = apps.map(app_id => ({ id: app_id }));
|
|
|
|
return { name_specifiers, id_specifiers };
|
|
};
|
|
|
|
const buildSuggestedAppsFromResolved = (resolved, name_specifier_count, options) => {
|
|
const suggested_apps = [];
|
|
|
|
const name_apps = resolved.slice(0, name_specifier_count);
|
|
suggested_apps.push(...name_apps);
|
|
|
|
const third_party_apps = resolved.slice(name_specifier_count);
|
|
for ( const third_party_app of third_party_apps ) {
|
|
if ( ! third_party_app ) continue;
|
|
if ( third_party_app.approved_for_opening_items ||
|
|
(options?.user && options.user.id === third_party_app.owner_user_id) )
|
|
{
|
|
suggested_apps.push(third_party_app);
|
|
}
|
|
}
|
|
|
|
const needs_codeapp = suggested_apps.some(app => app && app.name === 'editor');
|
|
return { suggested_apps, needs_codeapp };
|
|
};
|
|
|
|
const normalizeSuggestedApps = (suggested_apps) => (
|
|
suggested_apps.filter((suggested_app, pos, self) => {
|
|
// Remove any null values caused by calling `get_app()` for apps that don't exist.
|
|
// This happens on self-host because we don't include `code`, among others.
|
|
if ( ! suggested_app ) {
|
|
return false;
|
|
}
|
|
|
|
// Remove any duplicate entries
|
|
return self.indexOf(suggested_app) === pos;
|
|
})
|
|
);
|
|
|
|
const buildSuggestedAppsCacheKey = (fsentry, options) => {
|
|
const user_id = options?.user?.id ?? '';
|
|
const entry_id = fsentry?.uuid ?? fsentry?.uid ?? fsentry?.id ?? fsentry?.path ?? '';
|
|
const entry_name = fsentry?.name ?? '';
|
|
const entry_type = fsentry?.is_dir ? 'd' : 'f';
|
|
return `${user_id}:${entry_id}:${entry_type}:${entry_name}`;
|
|
};
|
|
|
|
const cloneSuggestedApps = (suggested_apps) => (
|
|
Array.isArray(suggested_apps)
|
|
? suggested_apps.map(app => (app ? { ...app } : app))
|
|
: suggested_apps
|
|
);
|
|
|
|
async function suggestedAppsForFsEntries (fsentries, options) {
|
|
if ( ! Array.isArray(fsentries) ) {
|
|
fsentries = [fsentries];
|
|
}
|
|
|
|
const batches = [];
|
|
const specifiers = [];
|
|
const results = new Array(fsentries.length);
|
|
const cacheKeysByIndex = new Map();
|
|
|
|
for ( let index = 0; index < fsentries.length; index++ ) {
|
|
const fsentry = fsentries[index];
|
|
if ( ! fsentry ) {
|
|
results[index] = [];
|
|
continue;
|
|
}
|
|
|
|
const cache_key = buildSuggestedAppsCacheKey(fsentry, options);
|
|
const cached = suggestedAppsCache.get(cache_key);
|
|
if ( cached !== undefined ) {
|
|
results[index] = cloneSuggestedApps(cached);
|
|
continue;
|
|
}
|
|
|
|
const { name_specifiers, id_specifiers } = await buildSuggestedAppSpecifiers(fsentry);
|
|
const entry_specifiers = [...name_specifiers, ...id_specifiers];
|
|
|
|
if ( entry_specifiers.length === 0 ) {
|
|
results[index] = [];
|
|
cacheKeysByIndex.set(index, cache_key);
|
|
continue;
|
|
}
|
|
|
|
const offset = specifiers.length;
|
|
specifiers.push(...entry_specifiers);
|
|
batches.push({
|
|
index,
|
|
offset,
|
|
count: entry_specifiers.length,
|
|
name_count: name_specifiers.length,
|
|
suggested_apps: [],
|
|
needs_codeapp: false,
|
|
});
|
|
cacheKeysByIndex.set(index, cache_key);
|
|
}
|
|
|
|
let resolved = [];
|
|
if ( specifiers.length > 0 ) {
|
|
resolved = await get_apps(specifiers);
|
|
}
|
|
|
|
let any_needs_codeapp = false;
|
|
for ( const batch of batches ) {
|
|
const slice = resolved.slice(batch.offset, batch.offset + batch.count);
|
|
const { suggested_apps, needs_codeapp } = buildSuggestedAppsFromResolved(slice,
|
|
batch.name_count,
|
|
options);
|
|
batch.suggested_apps = suggested_apps;
|
|
batch.needs_codeapp = needs_codeapp;
|
|
if ( needs_codeapp ) any_needs_codeapp = true;
|
|
}
|
|
|
|
let codeapp;
|
|
if ( any_needs_codeapp ) {
|
|
[codeapp] = await get_apps([{ name: 'codeapp' }]);
|
|
}
|
|
|
|
for ( const batch of batches ) {
|
|
let suggested_apps = batch.suggested_apps;
|
|
if ( batch.needs_codeapp && codeapp ) {
|
|
suggested_apps = [...suggested_apps, codeapp];
|
|
}
|
|
results[batch.index] = normalizeSuggestedApps(suggested_apps);
|
|
}
|
|
|
|
// Deduplicate results by ID
|
|
const deduplicatedResults = results.map(apps => {
|
|
if ( ! Array.isArray(apps) ) return apps;
|
|
const seen = new Set();
|
|
return apps.filter(app => {
|
|
if ( !app || !app.id ) return true;
|
|
if ( seen.has(app.id) ) return false;
|
|
seen.add(app.id);
|
|
return true;
|
|
});
|
|
});
|
|
|
|
for ( const [index, cache_key] of cacheKeysByIndex ) {
|
|
const apps = deduplicatedResults[index];
|
|
if ( apps !== undefined ) {
|
|
suggestedAppsCache.set(cache_key, cloneSuggestedApps(apps));
|
|
}
|
|
}
|
|
|
|
return deduplicatedResults;
|
|
}
|
|
|
|
async function suggestedAppForFsEntry (fsentry, options) {
|
|
const [result] = await suggestedAppsForFsEntries([fsentry], options);
|
|
return result;
|
|
}
|
|
|
|
async function get_taskbar_items (user, { icon_size, no_icons } = {}) {
|
|
/** @type BaseDatabaseAccessService */
|
|
const db = _servicesHolder.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: 'app-center', type: 'app' },
|
|
{ name: 'dev-center', type: 'app' },
|
|
{ name: 'editor', type: 'app' },
|
|
{ name: 'code', type: 'app' },
|
|
{ name: 'camera', type: 'app' },
|
|
{ name: 'recorder', 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
|
|
}
|
|
}
|
|
|
|
const app_specifiers = taskbar_items_from_db.map((taskbar_item_from_db) => {
|
|
if ( taskbar_item_from_db.type !== 'app' ) return {};
|
|
if ( taskbar_item_from_db.name === 'explorer' ) return {};
|
|
if ( taskbar_item_from_db.name ) {
|
|
return { name: taskbar_item_from_db.name };
|
|
}
|
|
if ( taskbar_item_from_db.id ) {
|
|
return { id: taskbar_item_from_db.id };
|
|
}
|
|
if ( taskbar_item_from_db.uid ) {
|
|
return { uid: taskbar_item_from_db.uid };
|
|
}
|
|
return {};
|
|
});
|
|
|
|
const taskbar_apps = await get_apps(app_specifiers, { rawIcon: !no_icons });
|
|
|
|
// 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' ) continue;
|
|
if ( taskbar_item_from_db.name === 'explorer' ) continue;
|
|
|
|
const item = taskbar_apps[index];
|
|
|
|
// 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;
|
|
|
|
if ( no_icons ) {
|
|
delete item.icon;
|
|
} else {
|
|
const svc_appIcon = _servicesHolder.services.get('app-icon');
|
|
const iconUrl = svc_appIcon.getSizedIconUrl({
|
|
appUid: item.uid,
|
|
size: icon_size,
|
|
});
|
|
|
|
item.icon = iconUrl;;
|
|
}
|
|
|
|
// add to final object
|
|
taskbar_items.push(item);
|
|
}
|
|
|
|
return taskbar_items;
|
|
}
|
|
|
|
function validate_signature_auth (url, action, options = {}) {
|
|
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.' };
|
|
}
|
|
|
|
if ( options.uid ) {
|
|
if ( query.get('uid') !== options.uid ) {
|
|
throw { message: 'Authentication failed. `uid` does not match.' };
|
|
}
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
/**
|
|
* 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, '');
|
|
let 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) {
|
|
const 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,
|
|
byte_format,
|
|
change_username,
|
|
chkperm,
|
|
convert_path_to_fsentry,
|
|
deleteUser,
|
|
get_descendants,
|
|
get_dir_size,
|
|
gen_public_token,
|
|
get_taskbar_items,
|
|
get_url_from_req,
|
|
generate_random_str,
|
|
get_app,
|
|
get_apps,
|
|
get_user,
|
|
invalidate_cached_user,
|
|
invalidate_cached_user_by_id,
|
|
hyphenize_confirm_code,
|
|
id2fsentry,
|
|
id2path,
|
|
id2uuid,
|
|
is_ancestor_of,
|
|
is_empty,
|
|
...require('./validation'),
|
|
is_temp_users_disabled,
|
|
is_user_signup_disabled,
|
|
jwt_auth,
|
|
number_format,
|
|
refresh_associations_cache,
|
|
resolve_glob,
|
|
seconds_to_string,
|
|
send_email_verification_code,
|
|
send_email_verification_token,
|
|
sign_file,
|
|
subdomain,
|
|
suggestedAppsForFsEntries,
|
|
suggestedAppForFsEntry,
|
|
df,
|
|
username_exists,
|
|
uuid2fsentry,
|
|
validate_fsentry_name,
|
|
validate_signature_auth,
|
|
tmp_provide_services,
|
|
identifying_uuid,
|
|
};
|