Files
puter/packages/backend/src/filesystem/FSNodeContext.js
T

865 lines
27 KiB
JavaScript

/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { get_user, get_dir_size, id2path, id2uuid, is_empty, is_shared_with_anyone, suggest_app_for_fsentry, get_app } = require("../helpers");
const config = require("../config");
const _path = require('path');
const { NodeInternalIDSelector, NodeChildSelector, NodeUIDSelector, RootNodeSelector, NodePathSelector } = require("./node/selectors");
const { Context } = require("../util/context");
const { MultiDetachable } = require("../util/listenerutil");
const { NodeRawEntrySelector } = require("./node/selectors");
const { DB_READ } = require("../services/database/consts");
const { UserActorType } = require("../services/auth/Actor");
const { PermissionUtil } = require("../services/auth/PermissionService");
/**
* Container for information collected about a node
* on the filesystem.
*
* Examples of such information include:
* - data collected by querying an fsentry
* - the location of a file's contents
*
* This is an implementation of the Facade design pattern,
* so information about a filesystem node should be collected
* via the methods on this class and not mutated directly.
*
* @class FSNodeContext
* @property {object} entry the filesystem entry
* @property {string} path the path to the filesystem entry
* @property {string} uid the UUID of the filesystem entry
*/
module.exports = class FSNodeContext {
static TYPE_FILE = { label: 'File' };
static TYPE_DIRECTORY = { label: 'Directory' };
static TYPE_SYMLINK = {};
static TYPE_SHORTCUT = {};
static TYPE_UNDETERMINED = {};
static SELECTOR_PRIORITY_ORDER = [
NodeRawEntrySelector,
RootNodeSelector,
NodeInternalIDSelector,
NodeUIDSelector,
NodeChildSelector,
NodePathSelector,
];
/**
* Creates an instance of FSNodeContext.
* @param {*} opt_identifier
* @param {*} opt_identifier.path a path to the filesystem entry
* @param {*} opt_identifier.uid a UUID of the filesystem entry
* @param {*} opt_identifier.id please pass mysql_id instead
* @param {*} opt_identifier.mysql_id a MySQL ID of the filesystem entry
*/
constructor ({ services, selector, fs }) {
this.log = services.get('log-service').create('fsnode-context');
this.selector_ = null;
this.selectors_ = [];
this.selector = selector;
this.entry = {};
this.found = undefined;
this.found_thumbnail = undefined;
selector.setPropertiesKnownBySelector(this);
this.services = services;
this.fileContentsFetcher = null;
this.fs = fs;
// Decorate all fetch methods with otel span
// TODO: language tool for traits; this is a trait
const fetch_methods = [
'fetchEntry',
'fetchPath',
'fetchSubdomains',
'fetchOwner',
'fetchShares',
'fetchVersions',
'fetchSize',
'fetchSuggestedApps',
'fetchIsEmpty',
];
for ( const method of fetch_methods ) {
const original_method = this[method];
this[method] = async (...args) => {
const tracer = this.services.get('traceService').tracer;
let result;
await tracer.startActiveSpan(`fs:nodectx:fetch:${method}`, async span => {
result = await original_method.call(this, ...args);
span.end();
});
return result;
}
}
}
set selector (new_selector) {
// Only add the selector if we don't already have it
for ( const selector of this.selectors_ ) {
if ( selector instanceof new_selector.constructor ) return;
}
this.selectors_.push(new_selector);
this.selector_ = new_selector;
}
get selector () {
return this.get_optimal_selector();
}
get_selector_of_type (cls) {
// Reverse iterate over selectors
for ( let i = this.selectors_.length - 1; i >= 0; i-- ) {
const selector = this.selectors_[i];
if ( selector instanceof cls ) {
return selector;
}
}
if ( cls.implyFromFetchedData ) {
return cls.implyFromFetchedData(this);
}
return null;
}
get_optimal_selector () {
for ( const cls of FSNodeContext.SELECTOR_PRIORITY_ORDER ) {
const selector = this.get_selector_of_type(cls);
if ( selector ) return selector;
}
this.log.warn('Failed to get optimal selector');
return this.selector_;
}
get isRoot () {
return this.path === '/';
}
async isUserDirectory () {
if ( this.isRoot ) return false;
if ( this.found === undefined ) {
await this.fetchEntry();
}
if ( this.isRoot ) return false;
if ( this.found === false ) return undefined;
return ! this.entry.parent_uid;
}
async getPathComponents () {
if ( this.isRoot ) return [];
let path = await this.get('path');
if ( path.startsWith('/') ) path = path.slice(1);
return path.split('/');
}
async getUserPart () {
if ( this.isRoot ) return;
const components = await this.getPathComponents();
return components[0];
}
async getPathSize () {
if ( this.isRoot ) return;
const components = await this.getPathComponents();
return components.length;
}
async exists (fetch_options = {}) {
await this.fetchEntry();
if ( ! this.found ) {
this.log.debug(
'here\'s why it doesn\'t exist: ' +
this.selector.describe() + ' -> ' +
this.uid + ' ' +
JSON.stringify(this.entry, null, ' ')
);
}
return this.found;
}
async fetchPath () {
if ( this.path ) return;
this.path = await this.services.get('information')
.with('fs.fsentry')
.obtain('fs.fsentry:path')
.exec(this.entry);
}
/**
* Fetches the filesystem entry associated with a
* filesystem node identified by a path or UID.
*
* If a UID exists, the path is ignored.
* If neither a UID nor a path is set, an error is thrown.
*
* @param {*} fsEntryFetcher fetches the filesystem entry
* @void
*/
async fetchEntry (fetch_entry_options = {}) {
if (
this.found === true &&
! fetch_entry_options.force &&
(
// thumbnail already fetched, or not asked for
! fetch_entry_options.thumbnail || this.entry?.thumbnail ||
this.found_thumbnail !== undefined
)
) {
return;
}
this.log.info('fetching entry: ' + this.selector.describe());
// All services at the top (DEVLOG-401)
const {
traceService,
fsEntryService,
fsEntryFetcher,
resourceService,
} = Context.get('services').values;
// await this.fs.resourceService
// .waitForResource(this.selector);
if ( fetch_entry_options.tracer == null ) {
fetch_entry_options.tracer = traceService.tracer;
}
if ( fetch_entry_options.op ) {
fetch_entry_options.trace_options = {
parent: fetch_entry_options.op.span,
};
}
let entry;
await new Promise (rslv => {
const detachables = new MultiDetachable();
let resolved = false;
const callback = (resolver) => {
// NOTE: commented out for now because it's too verbose
resolved = true;
detachables.detach();
rslv();
}
// either the resource is free
{
// no detachale because waitForResource returns a
// Promise that will be resolved when the resource
// is free no matter what, and then it will be
// garbage collected.
resourceService.waitForResource(
this.selector
).then(callback.bind(null, 'resourceService'));
}
// or pending information about the resource
// becomes available
{
// detachable is needed here because waitForEntry keeps
// a map of listeners in memory, and this event may
// never occur. If this never occurs, waitForResource
// is guaranteed to resolve eventually, and then this
// detachable will be detached by `callback` so the
// listener can be garbage collected.
const det = fsEntryService.waitForEntry(
this, callback.bind(null, 'fsEntryService'));
if ( det ) detachables.add(det);
}
});
if ( resourceService.getResourceInfo(this.uid) ) {
entry = await fsEntryService.get(this.uid, fetch_entry_options);
this.log.debug('got an entry from the future');
} else {
entry = await fsEntryFetcher.find(
this.selector, fetch_entry_options);
}
if ( ! entry ) {
this.log.info(`entry not found: ${this.selector.describe(true)}`);
}
if ( entry === null || typeof entry !== 'object' ) {
// TODO: this property shouldn't be set to false -
// this is set to false to avoid regressions with
// existing code.
this.entry = false;
this.found = false;
return;
}
this.found = true;
if ( entry.id ) {
this.selector = new NodeInternalIDSelector('mysql', entry.id, {
source: 'FSNodeContext optimization'
});
}
if ( ! this.uid && entry.uuid ) {
this.uid = entry.uuid;
}
if ( ! this.mysql_id && entry.id ) {
this.mysql_id = entry.id;
}
if ( ! this.path && entry.path ) {
this.path = entry.path;
}
if ( ! this.name && entry.name ) this.name = entry.name;
Object.assign(this.entry, entry);
}
/**
* Wait for an fsentry which might be enqueued for insertion
* into the database.
*
* This just calls ResourceService under the hood.
*/
async awaitStableEntry () {
const resourceService = Context.get('services').get('resourceService');
await resourceService.waitForResource(this.selector);
}
/**
* Fetches the subdomains associated with a directory or file
* and stores them on the `subdomains` property of the fsentry.
* @param {object} user the user is needed to query subdomains
* @param {bool} force fetch subdomains if they were already fetched
*
* @param fs:decouple-subdomains
*/
async fetchSubdomains (user, force) {
if ( ! this.entry.is_dir ) return;
const db = this.services.get('database').get(DB_READ, 'filesystem');
this.entry.subdomains = []
let subdomains = await db.read(
`SELECT * FROM subdomains WHERE root_dir_id = ? AND user_id = ?`,
[this.entry.id, user.id]
);
if(subdomains.length > 0){
subdomains.forEach((sd)=>{
this.entry.subdomains.push({
subdomain: sd.subdomain,
address: config.protocol + '://' + sd.subdomain + "." + 'puter.site',
uuid: sd.uuid,
})
})
this.entry.has_website = true;
}
}
/**
* Fetches the owner of a directory or file and stores it on the
* `owner` property of the fsentry.
* @param {bool} force fetch owner if it was already fetched
*/
async fetchOwner (force) {
if ( this.isRoot ) return;
const owner = await get_user({ id: this.entry.user_id });
this.entry.owner = {
username: owner.username,
email: owner.email,
};
}
/**
* Fetches shares, AKA "permissions", for a directory or file;
* then, stores them on the `permissions` property
* of the fsentry.
* @param {bool} force fetch shares if they were already fetched
*/
async fetchShares (force) {
if (this.entry.shares && ! force ) return;
const actor = Context.get('actor');
if ( ! actor ) {
this.entry.shares = { users: [], apps: [] };
return;
}
if ( ! (actor.type instanceof UserActorType) ) {
this.entry.shares = { users: [], apps: [] };
return;
}
const svc_permission = this.services.get('permission');
const permissions =
await svc_permission.query_issuer_permissions_by_prefix(
actor.type.user, `fs:${await this.get('uid')}:`);
this.entry.shares = { users: [], apps: [] };
for ( const user_perm of permissions.users ) {
const access =
PermissionUtil.split(user_perm.permission).slice(-1)[0];
this.entry.shares.users.push({
user: {
uid: user_perm.user.uuid,
username: user_perm.user.username,
},
access,
permission: user_perm.permission,
});
}
for ( const app_perm of permissions.apps ) {
const access =
PermissionUtil.split(app_perm.permission).slice(-1)[0];
this.entry.shares.apps.push({
app: {
icon: app_perm.app.icon,
uid: app_perm.app.uid,
name: app_perm.app.name,
},
access,
permission: app_perm.permission,
});
}
}
/**
* Fetches versions associated with a filesystem entry,
* then stores them on the `versions` property of
* the fsentry.
* @param {bool} force fetch versions if they were already fetched
*
* @todo fs:decouple-versions
*/
async fetchVersions (force) {
if ( this.entry.versions && ! force ) return;
const db = this.services.get('database').get(DB_READ, 'filesystem');
let versions = await db.read(
`SELECT * FROM fsentry_versions WHERE fsentry_id = ?`,
[this.entry.id]
);
const versions_tidy = [];
for (let index = 0; index < versions.length; index++) {
const version = versions[index];
let username = version.user_id ? (await get_user({id: version.user_id})).username : null;
versions_tidy.push({
id: version.version_id,
message: version.message,
timestamp: version.ts_epoch,
user: {
username: username,
}
})
}
this.entry.versions = versions_tidy;
}
/**
* Fetches the size of a file or directory if it was not
* already fetched.
* @param {object} user the user is needed to fetch the size
*/
async fetchSize (user) {
const { fsEntryService } = Context.get('services').values;
// we already have the size for files
if ( ! this.entry.is_dir ) return;
this.entry.size = await fsEntryService.get_recursive_size(
this.entry.uuid,
);
return this.entry.size;
}
async fetchSuggestedApps (user, force) {
if ( this.entry.suggested_apps && ! force ) return;
await this.fetchEntry();
if ( ! this.entry ) return;
this.entry.suggested_apps =
await suggest_app_for_fsentry(this.entry, { user });
}
async fetchIsEmpty () {
if ( ! this.entry ) return;
if ( ! this.entry.is_dir ) return;
if ( ! this.uid ) return;
this.entry.is_empty = await is_empty(this.uid);
}
// TODO: this is currently not called anywhere; for now it
// will never be fetched since sharing is not a priority.
async fetchIsShared () {
if ( ! this.mysql_id ) return;
this.entry.is_shared = await is_shared_with_anyone(this.mysql_id);
}
async fetchAll(fsEntryFetcher, user, force) {
await this.fetchEntry({ thumbnail: true });
await this.fetchSubdomains(user);
await this.fetchOwner();
await this.fetchShares();
await this.fetchVersions();
await this.fetchSize(user);
await this.fetchSuggestedApps(user);
await this.fetchIsEmpty();
}
async get (key) {
/*
This isn't supposed to stay like this!
""" if ( key === something ) return this """
^ we should use a map of getters instead
Ideally I'd like to make a class trait for classes like
FSNodeContext that provide a key-value facade to access
information about some entity.
*/
if ( this.found === false ) {
throw new Error(
`Tried to get ${key} of non-existent fsentry: ` +
this.selector.describe(true)
);
}
if ( key === 'entry' ) {
await this.fetchEntry();
if ( this.found === false ) {
throw new Error(
`Tried to get entry of non-existent fsentry: ` +
this.selector.describe(true)
);
}
return this.entry;
}
if ( key === 'path' ) {
if ( ! this.path ) await this.fetchEntry();
if ( this.found === false ) {
throw new Error(
`Tried to get path of non-existent fsentry: ` +
this.selector.describe(true)
);
}
if ( ! this.path ) {
// console.log('PATH WAS NOT ON ENTRY', this);
await this.fetchPath();
}
if ( ! this.path ) {
throw new Error(`failed to get path`);
}
return this.path;
}
if ( key === 'uid' ) {
await this.fetchEntry();
return this.uid;
}
if ( key === 'mysql-id' ) {
await this.fetchEntry();
return this.mysql_id;
}
const values_from_entry = ['immutable', 'user_id', 'name', 'size', 'parent_uid', 'metadata'];
for ( const k of values_from_entry ) {
if ( key === k ) {
await this.fetchEntry();
if ( this.found === false ) {
throw new Error(
`Tried to get ${key} of non-existent fsentry: ` +
this.selector.describe(true)
);
}
return this.entry[k];
}
}
if ( key === 'type' ) {
await this.fetchEntry();
// Longest ternary operator chain I've ever written?
return this.entry.is_shortcut
? FSNodeContext.TYPE_SHORTCUT
: this.entry.is_symlink
? FSNodeContext.TYPE_SYMLINK
: this.entry.is_dir
? FSNodeContext.TYPE_DIRECTORY
: FSNodeContext.TYPE_FILE;
}
if ( key === 'has-s3' ) {
await this.fetchEntry();
if ( this.entry.is_dir ) return false;
if ( this.entry.is_shortcut ) return false;
return true;
}
if ( key === 's3:location' ) {
await this.fetchEntry();
if ( ! await this.exists() ) {
throw new Error('file does not exist');
}
return {
bucket: this.entry.bucket,
bucket_region: this.entry.bucket_region,
key: this.entry.uuid,
};
}
if ( key === 'is-root' ) {
await this.fetchEntry();
return this.isRoot;
}
throw new Error(`unrecognize key for FSNodeContext.get: ${key}`);
}
async getParent () {
if ( this.isRoot ) {
throw new Error('tried to get parent of root');
}
if ( this.path ) {
const parent_fsNode = await this.fs.node({
path: _path.dirname(this.path),
})
return parent_fsNode;
}
if ( this.selector instanceof NodeChildSelector ) {
return this.fs.node(this.selector.parent);
}
if ( ! await this.exists() ) {
throw new Error('unable to get parent');
}
const parent_uid = this.entry.parent_uid;
if ( ! parent_uid ) {
return this.fs.node(new RootNodeSelector());
}
return this.fs.node(new NodeUIDSelector(parent_uid));
}
async getChild (name) {
// If we have a path, we can get an FSNodeContext for the child
// without fetching anything.
if ( this.path ) {
const child_fsNode = await this.fs.node({
path: _path.join(this.path, name),
})
return child_fsNode;
}
return await this.fs.node(new NodeChildSelector(
this.selector, name));
}
async getTarget () {
await this.fetchEntry();
const type = await this.get('type');
if ( type === FSNodeContext.TYPE_SYMLINK ) {
const path = await this.entry.symlink_path;
return await this.fs.node({ path });
}
if ( type === FSNodeContext.TYPE_SHORTCUT ) {
const target_id = await this.entry.shortcut_to;
return await this.fs.node({ mysql_id: target_id });
}
return this;
}
async is_above (child_fsNode) {
if ( this.isRoot ) return true;
const path_this = await this.get('path');
const path_child = await child_fsNode.get('path');
return path_child.startsWith(path_this + '/');
}
async is (fsNode) {
if ( this.mysql_id && fsNode.mysql_id ) {
return this.mysql_id === fsNode.mysql_id;
}
if ( this.uid && fsNode.uid ) {
return this.uid === fsNode.uid;
}
await this.fetchEntry();
await fsNode.fetchEntry();
return this.uid === fsNode.uid;
}
async getSafeEntry (fetch_options = {}) {
if ( this.found === false ) {
throw new Error(
`Tried to get entry of non-existent fsentry: ` +
this.selector.describe(true)
);
}
await this.fetchEntry(fetch_options);
const res = this.entry;
const fsentry = {};
// This property will not be serialized, but it can be checked
// by other code to verify that API calls do not send
// unsanitized filsystem entries.
Object.defineProperty(fsentry, '__is_safe__', {
enumerable: false,
value: true,
});
for ( const k in res ) {
fsentry[k] = res[k];
}
const info = this.services.get('information');
if ( ! this.uid && ! this.entry.uuid ) {
this.log.noticeme(
'whats even happening!?!? ' +
this.selector.describe() + ' ' +
JSON.stringify(this.entry, null, ' ')
);
}
// If fsentry was found by a path but the entry doesn't
// have a path, use the path that was used to find it.
fsentry.path = res.path ?? this.path ?? await info
.with('fs.fsentry:uuid')
.obtain('fs.fsentry:path')
.exec(this.uid ?? this.entry.uuid);
fsentry.dirname = _path.dirname(fsentry.path);
fsentry.dirpath = fsentry.dirname;
// Do not send internal IDs to clients
fsentry.id = res.uuid;
fsentry.parent_id = res.parent_uid;
// The client calls it uid, not uuid.
fsentry.uid = res.uuid;
delete fsentry.uuid;
delete fsentry.user_id;
if ( fsentry.suggested_apps ) {
for ( const app of fsentry.suggested_apps ) {
if ( app === null ) {
this.log.warn('null app');
continue;
}
delete app.owner_user_id;
}
}
// Do not send S3 bucket information to clients
delete fsentry.bucket;
delete fsentry.bucket_region;
// Use client-friendly IDs for shortcut_to
fsentry.shortcut_to = (res.shortcut_to
? await id2uuid(res.shortcut_to) : undefined);
fsentry.shortcut_to_path = (res.shortcut_to
? await id2path(res.shortcut_to) : undefined);
// Add file_request_url
if(res.file_request_token && res.file_request_token !== ''){
fsentry.file_request_url = config.origin +
'/upload?token=' + res.file_request_token;
}
if ( fsentry.associated_app_id ) {
const app = await get_app({ id: fsentry.associated_app_id });
fsentry.associated_app = app;
}
fsentry.is_dir = !! fsentry.is_dir;
// Ensure `size` is numeric
if ( fsentry.size ) {
fsentry.size = parseInt(fsentry.size);
}
return fsentry;
}
static sanitize_pending_entry_info (res) {
const fsentry = {};
// This property will not be serialized, but it can be checked
// by other code to verify that API calls do not send
// unsanitized filsystem entries.
Object.defineProperty(fsentry, '__is_safe__', {
enumerable: false,
value: true,
});
for ( const k in res ) {
fsentry[k] = res[k];
}
fsentry.dirname = _path.dirname(fsentry.path);
// Do not send internal IDs to clients
fsentry.id = res.uuid;
fsentry.parent_id = res.parent_uid;
// The client calls it uid, not uuid.
fsentry.uid = res.uuid;
delete fsentry.uuid;
delete fsentry.user_id;
// Do not send S3 bucket information to clients
delete fsentry.bucket;
delete fsentry.bucket_region;
delete fsentry.shortcut_to;
delete fsentry.shortcut_to_path;
return fsentry;
}
}