= Record<
string,
string | undefined
>,
>(
target: RequestHandler,
_context: ClassMethodDecoratorContext<
This,
(
this: This,
...args: Parameters>
) => ReturnType>
>,
) => {
_context.addInitializer(function () {
// eslint-disable-next-line no-invalid-this
const proto = Object.getPrototypeOf(this); // will be bound to class
if ( ! proto.__routes ) {
proto.__routes = [];
}
proto.__routes.push({
method,
path,
options: options as EndpointOptions | undefined,
adminUsernames: adminUsernames
? [...adminUsernames, 'admin', 'system']
: undefined,
allowedAppIds,
handler: target,
});
});
};
};
};
// HTTP method decorators
export const Get = createMethodDecorator('get');
export const Post = createMethodDecorator('post');
export const Put = createMethodDecorator('put');
export const Delete = createMethodDecorator('delete');
// TODO DS: add others as needed (patch, etc)
export class HttpError extends Error {
statusCode: number;
constructor (statusCode: StatusCodes, message: string, cause?: unknown) {
super(`${statusCode} - ${message}`, { cause });
this.statusCode = statusCode;
}
}
// Registers all routes from a decorated controller instance to an Express router
export class ExtensionController {
logger?: Console;
// TODO DS: make this work with other express-like routers
registerRoutes () {
const logger = this.logger || console;
const prefix = Object.getPrototypeOf(this).__controllerPrefix || '';
const adminsForController = Object.getPrototypeOf(this).__adminUsernames as
| string[]
| undefined;
const allowedAppIdsForController = Object.getPrototypeOf(this).__allowedAppIds as
| string[]
| undefined;
const routes: RouteMeta[] = Object.getPrototypeOf(this).__routes || [];
for ( const route of routes ) {
const fullPath = `${prefix}/${route.path}`.replace(/\/+/g, '/');
const adminsForRoute = route.adminUsernames
? adminsForController
? adminsForController.concat(route.adminUsernames)
: route.adminUsernames
: adminsForController
? adminsForController
: undefined;
const allowedAppIds = route.allowedAppIds
? allowedAppIdsForController
? allowedAppIdsForController.concat(route.allowedAppIds)
: route.allowedAppIds
: allowedAppIdsForController
? allowedAppIdsForController
: undefined;
if ( ! extension[route.method] ) {
throw new Error(`Unsupported HTTP method: ${route.method}`);
} else {
logger.log(`Registering route: [${route.method.toUpperCase()}] ${fullPath}`);
(extension[route.method] as RouterMethods[HttpMethod])(
fullPath,
route.options || {},
async (req, res, next) => {
try {
if ( adminsForRoute || allowedAppIds ) {
if ( ! req.actor ) {
throw new HttpError(StatusCodes.UNAUTHORIZED, 'Unauthenticated');
}
}
if ( adminsForRoute ) {
if ( ! adminsForRoute.includes(req.actor!.type.user.username) ) {
throw new HttpError(
StatusCodes.FORBIDDEN,
'Only admins may request this resource.',
);
}
}
if ( allowedAppIds ) {
if ( ( req.actor!.type?.app?.uid && !allowedAppIds.includes(req.actor!.type.app.uid) ) ) {
throw new HttpError(
StatusCodes.FORBIDDEN,
'This app may not request this resource.',
);
}
}
await route.handler.bind(this)(req, res, next);
} catch ( error ) {
if ( error instanceof HttpError ) {
res.status(error.statusCode).send({ error: error.message });
logger.warn('httpError:', error);
return;
}
if ( error instanceof Error ) {
res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ error: error.message });
logger.error('Non-http error:', error);
return;
}
res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ error: 'An unknown error occurred' });
logger.error('An unknown error occurred:', error);
}
},
);
}
}
}
}
================================================
FILE: extensions/extensionController/src/index.ts
================================================
import { Controller, Delete, ExtensionController, Get, HttpError, Post, Put } from './ExtensionController.js';
extension.exports = {
ExtensionController,
Controller,
Get,
Put,
Post,
Delete,
HttpError,
};
export {
Controller, Delete, ExtensionController, Get, HttpError, Post, Put,
};
================================================
FILE: extensions/extensionController/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2024",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"sourceMap": true,
"noEmitOnError": true,
"noImplicitAny": false,
"allowJs": true,
"checkJs": false,
},
"include": [
"./**/*.ts",
"./**/*.d.ts"
],
"exclude": [
"**/*.test.ts",
"**/*.spec.ts",
"**/test/**",
"**/tests/**",
"node_modules",
"dist",
"*.js"
]
}
================================================
FILE: extensions/hellodriver/config.json
================================================
{
"test": "yes I am a test"
}
================================================
FILE: extensions/hellodriver/hellodriver.js
================================================
const { kv } = extension.import('data');
const span = extension.span;
/**
* Here we create an interface called 'hello-world'. This interface
* specifies that any implementation of 'hello-world' should implement
* a method called `greet`. The greet method has a couple of optional
* parameters including `subject` and `locale`. The `locale` parameter
* is not implemented by the driver implementation in the proceeding
* definition, showing how driver implementations don't always need
* to support optional features.
*
* subject: the person to greet
* locale: a standard locale string (ex: en_US.UTF-8)
*/
extension.on('create.interfaces', event => {
event.createInterface('hello-world', {
description: 'Provides methods for generating greetings',
methods: {
greet: {
description: 'Returns a greeting',
parameters: {
subject: {
type: 'string',
optional: true,
},
locale: {
type: 'string',
optional: true,
},
},
},
},
});
});
/**
* Here we register an implementation of the `hello-world` driver
* interface. This implementation is called "no-frills" which is
* the most basic reasonable implementation of the interface. The
* default return value is "Hello, World!", but if subject is
* provided it will be "Hello, !".
*
* This implementation can be called from puter.js like this:
*
* await puter.call('hello-world', 'no-frills', 'greet', { subject: 'Dave' });
*
* If you get an authorization error it's because the user you're
* logged in as does not have permission to invoke the `no-frills`
* implementation of `hello-world`. Users must be granted the following
* permission to access this driver:
*
* service:no-frills:ii:hello-world
*
* The value of `` can be one of many "special" values
* to demonstrate capabilities of drivers or extensions, including:
* - `%fail%`: simulate an error response from a driver
* - `%config%`: return the effective configuration object
*/
extension.on('create.drivers', event => {
event.createDriver('hello-world', 'no-frills', {
greet ({ subject }) {
return `Hello, ${subject ?? 'World'}!`;
},
});
});
extension.on('create.drivers', event => {
event.createDriver('hello-world', 'slow-hello', {
greet: span('slow-hello:greet', async ({ subject }) => {
await new Promise(rslv => setTimeout(rslv, 1000));
await span.run(async () => {
await new Promise(rslv => setTimeout(rslv, 1000));
});
await new Promise(rslv => setTimeout(rslv, 1000));
return `Hello, ${subject ?? 'World'}!`;
}),
});
});
extension.on('create.drivers', event => {
event.createDriver('hello-world', 'extension-examples', {
greet ({ subject }) {
if ( subject === 'fail' ) {
throw new Error('failing on purpose');
}
if ( subject === 'config' ) {
return JSON.stringify(config ?? null);
}
const STR_KVSET = 'kv-set:';
if ( subject.startsWith(STR_KVSET) ) {
return kv.set({
key: 'extension-examples-test-key',
value: subject.slice(STR_KVSET.length),
});
}
if ( subject === 'kv-get' ) {
return kv.get({
key: 'extension-examples-test-key',
});
}
/* eslint-disable */
const STR_KVSET2 = 'kv-set-2:';
if ( subject.startsWith(STR_KVSET2) ) {
return kv.set(
'extension-examples-test-key',
subject.slice(STR_KVSET2.length),
);
}
if ( subject === 'kv-get-2' ) {
return kv.get(
'extension-examples-test-key',
);
}
/* eslint-enable */
return `Hello, ${subject ?? 'World'}!`;
},
});
});
/**
* Here we specify that both registered and temporary users are allowed
* to access the `no-frills` implementation of the `hello-world` driver.
*/
extension.on('create.permissions', event => {
event.grant_to_everyone('service:no-frills:ii:hello-world');
event.grant_to_everyone('service:slow-hello:ii:hello-world');
event.grant_to_everyone('service:extension-examples:ii:hello-world');
});
================================================
FILE: extensions/hellodriver/package.json
================================================
{
"name": "hellodriver",
"main": "hellodriver.js",
"type": "module"
}
================================================
FILE: extensions/imports_something.js
================================================
console.log('importing something...');
const { testval } = extension.import('exports_something');
console.log(testval);
extension.on('hello', event => {
console.log(`received "hello" from: ${event.from}`);
});
================================================
FILE: extensions/metering/config.json
================================================
{
"unlimitedUsage": false,
"unlimitedAllowList": [
"admin"
],
"allowedGlobalUsageUsers": [
"nj",
"salazareos"
],
"priority": 10
}
================================================
FILE: extensions/metering/controllers/UsageController.ts
================================================
/* global extension */
import type { BaseDatabaseAccessService } from '@heyputer/backend/src/services/database/BaseDatabaseAccessService.js';
import type { MeteringService } from '@heyputer/backend/src/services/MeteringService/MeteringService.js';
import type {
ExtensionRequest,
ExtensionResponse,
} from '../../api.d.ts';
const { Controller, Get, ExtensionController } = extension.import('extensionController');
@Controller('/metering')
export class UsageController extends ExtensionController {
#meteringService: MeteringService;
#sqlClient: BaseDatabaseAccessService;
constructor (
meteringService: MeteringService,
sqlClient: BaseDatabaseAccessService,
) {
super();
this.#meteringService = meteringService;
this.#sqlClient = sqlClient;
}
@Get('usage', { subdomain: 'api' })
async getUsage (req: ExtensionRequest, res: ExtensionResponse) {
const actor = req.actor;
if ( ! actor ) {
throw Error('actor not found in context');
}
const actorUsagePromise = this.#meteringService.getActorCurrentMonthUsageDetails(actor);
const actorAllowanceInfoPromise = this.#meteringService.getAllowedUsage(actor);
const [actorUsage, allowanceInfo] = await Promise.all([
actorUsagePromise,
actorAllowanceInfoPromise,
]);
res.status(200).json({ ...actorUsage, allowanceInfo });
return;
}
@Get('usage/:appIdOrName', { subdomain: 'api' })
async getUsageByApp (req: ExtensionRequest, res: ExtensionResponse) {
const actor = req.actor;
if ( ! actor ) {
throw Error('actor not found in context');
}
const appIdOrName = req.params.appIdOrName;
if ( ! appIdOrName ) {
res.status(400).json({ error: 'appId parameter is required' });
return;
}
if ( typeof appIdOrName !== 'string' ) {
res.status(400).json({ error: 'appId parameter must be a string' });
return;
}
let appId = appIdOrName;
if ( !appIdOrName.startsWith('app-') || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(appIdOrName.split('app-')[1]) ) {
// Check if the part after 'app-' is a valid UUID (v4)
const appRows = await this.#sqlClient.read(
'SELECT `uid` FROM `apps` WHERE `name` = ? LIMIT 1',
[appIdOrName],
);
if ( appRows.length > 0 ) {
appId = appRows[0].uid;
} else {
res.status(404).json({ error: 'App not found' });
return;
}
} else {
appId = appIdOrName;
}
const appUsage =
await this.#meteringService.getActorCurrentMonthAppUsageDetails(
actor,
appId,
);
res.status(200).json(appUsage);
return;
}
@Get('globalUsage', { subdomain: 'api' }, extension.config.allowedGlobalUsageUsers || [])
async getGlobalUsage (req: ExtensionRequest, res: ExtensionResponse) {
const actor = req.actor;
if ( ! actor ) {
throw Error('actor not found in context');
}
const globalUsage = await this.#meteringService.getGlobalUsage();
res.status(200).json(globalUsage);
return;
}
}
================================================
FILE: extensions/metering/eventListeners/subscriptionEvents.ts
================================================
extension.on('metering:overrideDefaultSubscription', async (event) => {
// bit of a stub implementation for OSS, technically can be always free if you set this config true
if ( config.unlimitedUsage ) {
console.warn('WARNING!!! unlimitedUsage is enabled, this is not recommended for production use');
event.defaultSubscriptionId = 'unlimited';
}
});
extension.on('metering:registerAvailablePolicies', async (event) => {
// bit of a stub implementation for OSS, technically can be always free if you set this config true
if ( config.unlimitedUsage || config.unlimitedAllowList?.length ) {
event.availablePolicies.push({
id: 'unlimited',
monthUsageAllowance: 5_000_000 * 1_000_000 * 100, // unless you're like, jeff's, mark's, and elon's illegitamate son, you probably won't hit $5m a month
monthlyStorageAllowance: 100_000 * 1024 * 1024, // 100MiB but ignored in local dev
});
}
});
extension.on('metering:getUserSubscription', async (event) => {
const userName = event?.actor?.type?.user?.username;
if ( config.unlimitedAllowList?.includes(userName) ) {
event.userSubscriptionId;
}
else {
event.userSubscriptionId = event?.actor?.type?.user?.subscription?.active ? event.actor.type.user.subscription?.tier : undefined;
}
// default location for user sub, but can techinically be anywhere else or fetched on request
});
================================================
FILE: extensions/metering/main.ts
================================================
import { UsageController } from './controllers/UsageController.js';
import './eventListeners/subscriptionEvents.js';
const meteringService = extension.import('service:meteringService');
const sqlClient = extension.import('service:database');
const controller = new UsageController(meteringService, sqlClient);
controller.registerRoutes();
================================================
FILE: extensions/metering/package.json
================================================
{
"name": "@heyputer/extension-metering-service",
"main": "main.js",
"type": "module",
"scripts": {
"postinstall": "tsc --noCheck"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}
================================================
FILE: extensions/metering/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2024",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"sourceMap": true,
"noEmitOnError": true,
"noImplicitAny": false,
"allowJs": true,
"checkJs": false,
},
"include": [
"./**/*.ts",
"./**/*.d.ts"
],
"exclude": [
"**/*.test.ts",
"**/*.spec.ts",
"**/test/**",
"**/tests/**",
"node_modules",
"dist",
"*.js"
]
}
================================================
FILE: extensions/metering/types.ts
================================================
import '../api.js';
================================================
FILE: extensions/puterfs/PuterFSProvider.js
================================================
/*
* 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 .
*/
const STUCK_STATUS_TIMEOUT = 10 * 1000;
const STUCK_ALARM_TIMEOUT = 20 * 1000;
// Temporary limit
const MAX_DIRECTORY_DEPTH = 35;
import crypto from 'node:crypto';
import path_ from 'node:path';
import { v4 as uuidv4 } from 'uuid';
const { db } = extension.import('data');
const svc_metering = extension.import('service:meteringService');
const svc_fs = extension.import('service:filesystem');
const { stuck_detector_stream, hashing_stream } = extension.import('core').util.streamutil;
// TODO: filesystem providers should not need to call EventService
const svc_event = extension.import('service:event');
// TODO: filesystem providers REALLY SHOULD NOT implement ACL logic!
const svc_acl = extension.import('service:acl');
// TODO: these services ought to be part of this extension
const svc_size = extension.import('service:sizeService');
const svc_resource = extension.import('service:resourceService');
// TODO: depending on mountpoint service will not be necessary
// once the storage provider is moved to this extension
const svc_mountpoint = extension.import('service:mountpoint');
const {
APIError,
Actor,
Context,
UserActorType,
TDetachable,
MultiDetachable,
} = extension.import('core');
const {
get_user,
} = extension.import('core').util.helpers;
const {
ParallelTasks,
getTracer,
} = extension.import('core').util.otelutil;
const {
TYPE_DIRECTORY,
} = extension.import('core').fs;
const {
NodeChildSelector,
NodeUIDSelector,
NodeInternalIDSelector,
NodeRawEntrySelector,
} = extension.import('core').fs.selectors;
const {
FSNodeContext,
capabilities,
} = extension.import('fs');
const {
// MODE_READ,
MODE_WRITE,
} = extension.import('fs').lock;
// ^ Yep I know, import('fs') and import('core').fs is confusing and
// redundant... this will be cleaned up as the new API is developed
const {
// MODE_READ,
RESOURCE_STATUS_PENDING_CREATE,
} = extension.import('fs').resource;
const {
UploadProgressTracker,
} = extension.import('fs').util;
export default class PuterFSProvider {
constructor ({ fsEntryController, storageController }) {
this.fsEntryController = fsEntryController;
this.storageController = storageController;
this.name = 'puterfs';
}
// #region depth limit helpers
/**
* Number of path segments (directory depth). Root or empty path = 0.
* @param {string} path
* @returns {number}
*/
#pathDepth (path) {
if ( !path || typeof path !== 'string' ) return 0;
return path_.normalize(path).split(path_.sep).filter(Boolean).length;
}
/**
* Max relative depth of the source tree (0 for a file, 1+ for directory tree).
* Used to enforce MAX_DIRECTORY_DEPTH when moving or copying.
* @param {FSNode} node
* @returns {Promise}
*/
async #getSourceTreeMaxRelativeDepth (node) {
await node.fetchEntry();
if ( ! node.entry.is_dir ) return 0;
const child_uuids = await this.fsEntryController.fast_get_direct_descendants(await node.get('uid'));
let max = 0;
for ( const child_uuid of child_uuids ) {
const child_node = await svc_fs.node(new NodeUIDSelector(child_uuid));
const child_relative = 1 + await this.#getSourceTreeMaxRelativeDepth(child_node);
max = Math.max(max, child_relative);
}
return max;
}
/**
* Throws if destination depth plus source tree depth would exceed MAX_DIRECTORY_DEPTH.
* @param {number} destinationPathDepth
* @param {FSNode} sourceNode
*/
async #assertDepthLimitForTreeOp (destinationPathDepth, sourceNode) {
const source_relative = await this.#getSourceTreeMaxRelativeDepth(sourceNode);
const max_depth = destinationPathDepth + source_relative;
if ( max_depth > MAX_DIRECTORY_DEPTH ) {
throw APIError.create('directory_depth_limit_exceeded', null, {
limit: MAX_DIRECTORY_DEPTH,
would_be: max_depth,
});
}
}
// #endregion
// TODO: should this be a static member instead?
get_capabilities () {
return new Set([
capabilities.THUMBNAIL,
capabilities.UPDATE_THUMBNAIL,
capabilities.UUID,
capabilities.OPERATION_TRACE,
capabilities.READDIR_UUID_MODE,
capabilities.READDIRSTAT_UUID,
capabilities.PUTER_SHORTCUT,
capabilities.COPY_TREE,
capabilities.GET_RECURSIVE_SIZE,
capabilities.READ,
capabilities.WRITE,
capabilities.CASE_SENSITIVE,
capabilities.SYMLINK,
capabilities.TRASH,
]);
}
// #region PuterOnly
async update_thumbnail ({ context, node, thumbnail }) {
const {
actor: inputActor,
} = context.values;
const actor = inputActor ?? Context.get('actor');
context = context ?? Context.get();
const services = context.get('services');
// TODO: this ACL check should not be here, but there's no LL method yet
// and it's possible we will never implement the thumbnail
// capability for any other filesystem type
const svc_acl = services.get('acl');
if ( ! await svc_acl.check(actor, node, 'write') ) {
throw await svc_acl.get_safe_acl_error(actor, node, 'write');
}
const uid = await node.get('uid');
const entryOp = await this.fsEntryController.update(uid, {
thumbnail,
});
(async () => {
await entryOp.awaitDone();
svc_event.emit('fs.write.file', {
node,
context,
});
})();
return node;
}
async puter_shortcut ({ parent, name, user, target }) {
const user_id = user?.id ?? Context.get('actor')?.type?.user?.id;
await target.fetchEntry({ thumbnail: true });
const ts = Math.round(Date.now() / 1000);
const uid = uuidv4();
svc_resource.register({
uid,
status: RESOURCE_STATUS_PENDING_CREATE,
});
const raw_fsentry = {
is_shortcut: 1,
shortcut_to: target.mysql_id,
is_dir: target.entry.is_dir,
thumbnail: target.entry.thumbnail,
uuid: uid,
parent_uid: await parent.get('uid'),
path: path_.join(await parent.get('path'), name),
user_id: user_id,
name,
created: ts,
updated: ts,
modified: ts,
immutable: false,
};
const entryOp = await this.fsEntryController.insert(raw_fsentry);
(async () => {
await entryOp.awaitDone();
svc_resource.free(uid);
})();
const node = await svc_fs.node(new NodeUIDSelector(uid));
svc_event.emit('fs.create.shortcut', {
node,
context: Context.get(),
});
return node;
}
// #endregion
// #region Optimization
/**
* The readdirstat_uuid operation is only available for filesystem
* immplementations with READDIR_UUID_MODE enabled. This implements
* an optimized readdir operation when the UUID is already known.
* @param {*} param0
*/
async readdirstat_uuid ({
uuid,
options = {},
}) {
const entries = await this.fsEntryController.get_descendants_full(uuid, options);
const nodes = Promise.all(Array.prototype.map.call(entries, raw_entry => {
const node = svc_fs.node(new NodeRawEntrySelector(raw_entry, {
found_thumbnail: options.thumbnail,
}));
node.found = true; // TODO: how is it possible for this to be false?
return node;
}));
return nodes;
};
// #endregion
// #region Standard FS
/**
* Check if a given node exists.
*
* @param {Object} param
* @param {NodeSelector} param.selector - The selector used for checking.
* @returns {Promise} - True if the node exists, false otherwise.
*/
async quick_check ({
selector,
}) {
// shortcut: has full path
if ( selector?.path ) {
const entry = await this.fsEntryController.findByPath(selector.path);
return Boolean(entry);
}
// shortcut: has uid
if ( selector?.uid ) {
const entry = await this.fsEntryController.findByUID(selector.uid);
return Boolean(entry);
}
// shortcut: parent uid + child name
if ( selector instanceof NodeChildSelector && selector.parent instanceof NodeUIDSelector ) {
return await this.fsEntryController.nameExistsUnderParent(selector.parent.uid,
selector.name);
}
// shortcut: parent id + child name
if ( selector instanceof NodeChildSelector && selector.parent instanceof NodeInternalIDSelector ) {
return await this.fsEntryController.nameExistsUnderParentID(selector.parent.id,
selector.name);
}
return false;
}
async unlink ({ context, node, options = {} }) {
if ( await node.get('type') === TYPE_DIRECTORY ) {
throw new APIError(409, 'Cannot unlink a directory.');
}
await this.#rmnode({ context, node, options });
}
async rmdir ({ context, node, options = {} }) {
if ( await node.get('type') !== TYPE_DIRECTORY ) {
throw new APIError(409, 'Cannot rmdir a file.');
}
if ( await node.get('immutable') ) {
throw APIError.create('immutable');
}
const children = await this.fsEntryController.fast_get_direct_descendants(await node.get('uid'));
if ( children.length > 0 && !options.ignore_not_empty ) {
throw APIError.create('not_empty');
}
await this.#rmnode({ context, node, options });
}
/**
* Create a new directory.
*
* @param {Object} param
* @param {Context} param.context
* @param {FSNode} param.parent
* @param {string} param.name
* @param {boolean} param.immutable
* @returns {Promise}
*/
async mkdir ({ context, parent, name, immutable }) {
const { actor, thumbnail } = context.values;
const ts = Math.round(Date.now() / 1000);
const uid = uuidv4();
const existing = await svc_fs.node(new NodeChildSelector(parent.selector, name));
if ( await existing.exists() ) {
throw APIError.create('item_with_same_name_exists', null, {
entry_name: name,
});
}
if ( ! await parent.exists() ) {
throw APIError.create('subject_does_not_exist');
}
const new_path = path_.join(await parent.get('path'), name);
if ( this.#pathDepth(new_path) > MAX_DIRECTORY_DEPTH ) {
throw APIError.create('directory_depth_limit_exceeded', null, {
limit: MAX_DIRECTORY_DEPTH,
would_be: this.#pathDepth(new_path),
});
}
svc_resource.register({
uid,
status: RESOURCE_STATUS_PENDING_CREATE,
});
const raw_fsentry = {
is_dir: 1,
uuid: uid,
parent_uid: await parent.get('uid'),
path: path_.join(await parent.get('path'), name),
user_id: actor.type.user.id,
name,
created: ts,
accessed: ts,
modified: ts,
immutable: immutable ?? false,
...(thumbnail ? {
thumbnail: thumbnail,
} : {}),
};
console.log('raw fsentry', raw_fsentry);
const entryOp = await this.fsEntryController.insert(raw_fsentry);
await entryOp.awaitDone();
svc_resource.free(uid);
const node = await svc_fs.node(new NodeUIDSelector(uid));
svc_event.emit('fs.create.directory', {
node,
context: Context.get(),
});
return node;
}
async read ({ context, node, version_id, range }) {
const svc_mountpoint = context.get('services').get('mountpoint');
const storage = svc_mountpoint.get_storage(this.constructor.name);
const location = await node.get('s3:location') ?? {};
const stream = (await storage.create_read_stream(await node.get('uid'), {
// TODO: fs:decouple-s3
bucket: location.bucket,
bucket_region: location.bucket_region,
version_id,
key: location.key,
memory_file: node.entry,
...(range ? { range } : {}),
}));
return stream;
}
async stat ({
selector,
options,
controls,
node,
}) {
// For Puter FS nodes, we assume we will obtain all properties from
// fsEntryController, except for 'thumbnail' unless it's
// explicitly requested.
if ( options.tracer == null ) {
options.tracer = getTracer();
}
if ( options.op ) {
options.trace_options = {
parent: options.op.span,
};
}
let entry;
// stat doesn't work with RawEntrySelector
if ( selector instanceof NodeRawEntrySelector ) {
selector = new NodeUIDSelector(node.uid);
}
await new Promise (rslv => {
const detachables = new MultiDetachable();
const callback = (_resolver) => {
detachables.as(TDetachable).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.
svc_resource.waitForResource(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 = this.fsEntryController.waitForEntry(node, callback.bind(null, 'fsEntryService'));
if ( det ) detachables.add(det);
}
});
const maybe_uid = node.uid;
if ( svc_resource.getResourceInfo(maybe_uid) ) {
entry = await this.fsEntryController.get(maybe_uid, options);
controls.log.debug('got an entry from the future');
} else {
entry = await this.fsEntryController.find(selector, options);
}
if ( ! entry ) {
if ( this.log_fsentriesNotFound ) {
controls.log.warn(`entry not found: ${selector.describe(true)}`);
}
}
if ( entry === null || typeof entry !== 'object' ) {
return null;
}
if ( entry.id ) {
controls.provide_selector(new NodeInternalIDSelector('mysql', entry.id, {
source: 'FSNodeContext optimization',
}));
}
return entry;
}
async copy_tree ({ context, source, parent, target_name }) {
// Context
const actor = (context ?? Context).get('actor');
const user = actor.type.user;
const tracer = getTracer();
const uuid = uuidv4();
const timestamp = Math.round(Date.now() / 1000);
await parent.fetchEntry();
await source.fetchEntry({ thumbnail: true });
const destination_path = path_.join(await parent.get('path'), target_name);
await this.#assertDepthLimitForTreeOp(this.#pathDepth(destination_path), source);
// New filesystem entry
const raw_fsentry = {
uuid,
is_dir: source.entry.is_dir,
...(source.entry.is_shortcut ? {
is_shortcut: source.entry.is_shortcut,
shortcut_to: source.entry.shortcut_to,
} : {}),
parent_uid: parent.uid,
name: target_name,
created: timestamp,
modified: timestamp,
path: path_.join(await parent.get('path'), target_name),
// if property exists but the value is undefined,
// it will still be included in the INSERT, causing
// an error
...(source.entry.thumbnail ?
{ thumbnail: source.entry.thumbnail } : {}),
user_id: user.id,
};
svc_event.emit('fs.pending.file', {
fsentry: FSNodeContext.sanitize_pending_entry_info(raw_fsentry),
context: context,
});
if ( await source.get('has-s3') ) {
Object.assign(raw_fsentry, {
size: source.entry.size,
associated_app_id: source.entry.associated_app_id,
bucket: source.entry.bucket,
bucket_region: source.entry.bucket_region,
});
await tracer.startActiveSpan('fs:cp:storage-copy', async span => {
let progress_tracker = new UploadProgressTracker();
svc_event.emit('fs.storage.progress.copy', {
upload_tracker: progress_tracker,
context,
meta: {
item_uid: uuid,
item_path: raw_fsentry.path,
},
});
// const storage = new PuterS3StorageStrategy({ services: svc });
const storage = context.get('storage');
const state_copy = storage.create_copy();
await state_copy.run({
src_node: source,
dst_storage: {
key: uuid,
bucket: raw_fsentry.bucket,
bucket_region: raw_fsentry.bucket_region,
},
storage_api: { progress_tracker },
});
span.end();
});
}
{
await svc_size.add_node_size(undefined, source, user);
}
svc_resource.register({
uid: uuid,
status: RESOURCE_STATUS_PENDING_CREATE,
});
const entryOp = await this.fsEntryController.insert(raw_fsentry);
let node;
const tasks = new ParallelTasks({ tracer, max: 4 });
await context.arun('fs:cp:parallel-portion', async () => {
// Add child copy tasks if this is a directory
if ( source.entry.is_dir ) {
const children = await this.fsEntryController.fast_get_direct_descendants(source.uid);
for ( const child_uuid of children ) {
tasks.add('fs:cp:copy-child', async () => {
const child_node = await svc_fs.node(new NodeUIDSelector(child_uuid));
const child_name = await child_node.get('name');
await this.copy_tree({
context,
source: await svc_fs.node(new NodeUIDSelector(child_uuid)),
parent: await svc_fs.node(new NodeUIDSelector(uuid)),
target_name: child_name,
});
});
}
}
// Add task to await entry
tasks.add('fs:cp:entry-op', async () => {
await entryOp.awaitDone();
svc_resource.free(uuid);
const copy_fsNode = await svc_fs.node(new NodeUIDSelector(uuid));
copy_fsNode.entry = raw_fsentry;
copy_fsNode.found = true;
copy_fsNode.path = raw_fsentry.path;
node = copy_fsNode;
svc_event.emit('fs.create.file', {
node,
context,
});
}, { force: true });
await tasks.awaitAll();
});
node = node || await svc_fs.node(new NodeUIDSelector(uuid));
// TODO: What event do we emit? How do we know if we're overwriting?
return node;
}
async move ({ context, node, new_parent, new_name, metadata }) {
const old_path = await node.get('path');
const new_path = path_.join(await new_parent.get('path'), new_name);
await this.#assertDepthLimitForTreeOp(this.#pathDepth(new_path), node);
const op_update = await this.fsEntryController.update(node.uid, {
...(
await node.get('parent_uid') !== await new_parent.get('uid')
? { parent_uid: await new_parent.get('uid') }
: {}
),
path: new_path,
name: new_name,
...(metadata ? { metadata } : {}),
});
node.entry.name = new_name;
node.entry.path = new_path;
// NOTE: this is a safeguard passed to update_child_paths to isolate
// changes to the owner's directory tree, ut this may need to be
// removed in the future.
const user_id = await node.get('user_id');
await op_update.awaitDone();
await svc_fs.update_child_paths(old_path, node.entry.path, user_id);
const promises = [];
promises.push(svc_event.emit('fs.move.file', {
context,
moved: node,
old_path,
}));
promises.push(svc_event.emit('fs.rename', {
uid: await node.get('uid'),
new_name,
}));
return node;
}
async readdir ({ node }) {
const uuid = await node.get('uid');
const child_uuids = await this.fsEntryController.fast_get_direct_descendants(uuid);
return child_uuids;
}
async directory_has_name ({ parent, name }) {
const uid = await parent.get('uid');
let check_dupe = await db.read(
'SELECT `id` FROM `fsentries` WHERE `parent_uid` = ? AND name = ? LIMIT 1',
[uid, name],
);
return !!check_dupe[0];
}
/**
* Write a new file to the filesystem. Throws an error if the destination
* already exists.
*
* @param {Object} param
* @param {Context} param.context
* @param {FSNode} param.parent: The parent directory of the file.
* @param {string} param.name: The name of the file.
* @param {File} param.file: The file to write.
* @returns {Promise}
*/
async write_new ({ context, parent, name, file }) {
console.log('calling write new');
const {
tmp, fsentry_tmp, message, actor: inputActor, app_id,
} = context.values;
const actor = inputActor ?? Context.get('actor');
const uid = uuidv4();
// determine bucket region
let bucket_region = global_config.s3_region ?? global_config.region;
let bucket = global_config.s3_bucket;
if ( ! await svc_acl.check(actor, parent, 'write') ) {
throw await svc_acl.get_safe_acl_error(actor, parent, 'write');
}
const storage_resp = await this.#storage_upload({
uuid: uid,
bucket,
bucket_region,
file,
tmp: {
...tmp,
path: path_.join(await parent.get('path'), name),
},
});
fsentry_tmp.thumbnail = await fsentry_tmp.thumbnail_promise;
delete fsentry_tmp.thumbnail_promise;
const timestamp = Math.round(Date.now() / 1000);
const raw_fsentry = {
uuid: uid,
is_dir: 0,
user_id: actor.type.user.id,
created: timestamp,
accessed: timestamp,
modified: timestamp,
parent_uid: await parent.get('uid'),
name,
size: file.size,
path: path_.join(await parent.get('path'), name),
...fsentry_tmp,
bucket_region,
bucket,
associated_app_id: app_id ?? null,
};
svc_event.emit('fs.pending.file', {
fsentry: FSNodeContext.sanitize_pending_entry_info(raw_fsentry),
context,
});
svc_resource.register({
uid,
status: RESOURCE_STATUS_PENDING_CREATE,
});
const filesize = file.size;
svc_size.change_usage(actor.type.user.id, filesize);
// Meter ingress
const ownerId = await parent.get('user_id');
const ownerActor = new Actor({
type: new UserActorType({
user: await get_user({ id: ownerId }),
}),
});
svc_metering.incrementUsage(ownerActor, 'filesystem:ingress:bytes', filesize);
const entryOp = await this.fsEntryController.insert(raw_fsentry);
(async () => {
await entryOp.awaitDone();
svc_resource.free(uid);
const new_item_node = await svc_fs.node(new NodeUIDSelector(uid));
const new_item = await new_item_node.get('entry');
const store_version_id = storage_resp.VersionId;
if ( store_version_id ) {
// insert version into db
db.write('INSERT INTO `fsentry_versions` (`user_id`, `fsentry_id`, `fsentry_uuid`, `version_id`, `message`, `ts_epoch`) VALUES (?, ?, ?, ?, ?, ?)',
[
actor.type.user.id,
new_item.id,
new_item.uuid,
store_version_id,
message ?? null,
timestamp,
]);
}
})();
const node = await svc_fs.node(new NodeUIDSelector(uid));
svc_event.emit('fs.create.file', {
node,
context,
});
return node;
}
/**
* Overwrite an existing file. Throws an error if the destination does not
* exist.
*
* @param {Object} param
* @param {Context} param.context
* @param {FSNodeContext} param.node: The node to write to.
* @param {File} param.file: The file to write.
* @returns {Promise}
*/
async write_overwrite ({ context, node, file }) {
const {
tmp, fsentry_tmp, message, actor: inputActor,
} = context.values;
const actor = inputActor ?? Context.get('actor');
if ( ! await svc_acl.check(actor, node, 'write') ) {
throw await svc_acl.get_safe_acl_error(actor, node, 'write');
}
const uid = await node.get('uid');
const bucket_region = node.entry.bucket_region;
const bucket = node.entry.bucket;
const state_upload = await this.#storage_upload({
uuid: node.entry.uuid,
bucket,
bucket_region,
file,
tmp: {
...tmp,
path: await node.get('path'),
},
});
if ( fsentry_tmp?.thumbnail_promise ) {
fsentry_tmp.thumbnail = await fsentry_tmp.thumbnail_promise;
delete fsentry_tmp.thumbnail_promise;
}
const ts = Math.round(Date.now() / 1000);
const raw_fsentry_delta = {
modified: ts,
accessed: ts,
size: file.size,
...fsentry_tmp,
};
svc_resource.register({
uid,
status: RESOURCE_STATUS_PENDING_CREATE,
});
const filesize = file.size;
svc_size.change_usage(actor.type.user.id, filesize);
// Meter ingress
const ownerId = await node.get('user_id');
const ownerActor = new Actor({
type: new UserActorType({
user: await get_user({ id: ownerId }),
}),
});
svc_metering.incrementUsage(ownerActor, 'filesystem:ingress:bytes', filesize);
const entryOp = await this.fsEntryController.update(uid, raw_fsentry_delta);
// depends on fsentry, does not depend on S3
const entryOpPromise = (async () => {
await entryOp.awaitDone();
svc_resource.free(uid);
})();
(async () => {
await entryOpPromise;
svc_event.emit('fs.write.file', {
node,
context,
});
})();
// TODO (xiaochen): determine if this can be removed, post_insert handler need
// to skip events from other servers (why? 1. current write logic is inside
// the local server 2. broadcast system conduct "fire-and-forget" behavior)
state_upload.post_insert({
db, user: actor.type.user, node, uid, message, ts,
});
return node;
}
async get_recursive_size ({ node }) {
const uuid = await node.get('uid');
const cte_query = `
WITH RECURSIVE descendant_cte AS (
SELECT uuid, parent_uid, size
FROM fsentries
WHERE parent_uid = ?
UNION ALL
SELECT f.uuid, f.parent_uid, f.size
FROM fsentries f
INNER JOIN descendant_cte d
ON f.parent_uid = d.uuid
)
SELECT SUM(size) AS total_size FROM descendant_cte
`;
const rows = await db.read(cte_query, [uuid]);
return rows[0].total_size;
}
// #endregion
// #region internal
/**
* @param {Object} param
* @param {File} param.file: The file to write.
* @returns
*/
async #storage_upload ({
uuid,
bucket,
bucket_region,
file,
tmp,
}) {
const storage = svc_mountpoint.get_storage(this.constructor.name);
bucket ??= global_config.s3_bucket;
bucket_region ??= global_config.s3_region ?? global_config.region;
let upload_tracker = new UploadProgressTracker();
svc_event.emit('fs.storage.upload-progress', {
upload_tracker,
context: Context.get(),
meta: {
item_uid: uuid,
item_path: tmp.path,
},
});
if ( ! file.buffer ) {
let stream = file.stream;
let alarm_timeout = null;
stream = stuck_detector_stream(stream, {
timeout: STUCK_STATUS_TIMEOUT,
on_stuck: () => {
console.warn('Upload stream stuck might be stuck', {
bucket_region,
bucket,
uuid,
});
alarm_timeout = setTimeout(() => {
extension.errors.report('fs.write.s3-upload', {
message: 'Upload stream stuck for too long',
alarm: true,
extra: {
bucket_region,
bucket,
uuid,
},
});
}, STUCK_ALARM_TIMEOUT);
},
on_unstuck: () => {
clearTimeout(alarm_timeout);
},
});
file = { ...file, stream };
}
let hashPromise;
if ( file.buffer ) {
const hash = crypto.createHash('sha256');
hash.update(file.buffer);
hashPromise = Promise.resolve(hash.digest('hex'));
} else {
const hs = hashing_stream(file.stream);
file.stream = hs.stream;
hashPromise = hs.hashPromise;
}
hashPromise.then(hash => {
svc_event.emit('outer.fs.write-hash', {
hash, uuid,
});
});
const state_upload = storage.create_upload();
try {
await this.storageController.upload({
uid: uuid,
file,
storage_meta: { bucket, bucket_region },
storage_api: { progress_tracker: upload_tracker },
});
} catch (e) {
extension.errors.report('fs.write.storage-upload', {
source: e || new Error('unknown'),
trace: true,
alarm: true,
extra: {
bucket_region,
bucket,
uuid,
},
});
throw APIError.create('upload_failed');
}
return state_upload;
}
async #rmnode ({ node, options }) {
// Services
if ( !options.override_immutable && await node.get('immutable') ) {
throw new APIError(403, 'File is immutable.');
}
const userId = await node.get('user_id');
const fileSize = await node.get('size');
svc_size.change_usage(userId,
-1 * fileSize);
const ownerActor = new Actor({
type: new UserActorType({
user: await get_user({ id: userId }),
}),
});
svc_metering.incrementUsage(ownerActor, 'filesystem:delete:bytes', fileSize);
const tracer = getTracer();
const tasks = new ParallelTasks({ tracer, max: 4 });
tasks.add('remove-fsentry', async () => {
await this.fsEntryController.delete(await node.get('uid'));
});
if ( await node.get('has-s3') ) {
tasks.add('remove-from-s3', async () => {
// const storage = new PuterS3StorageStrategy({ services: svc });
const storage = Context.get('storage');
const state_delete = storage.create_delete();
await state_delete.run({
node: node,
});
});
}
await tasks.awaitAll();
}
// #endregion
}
================================================
FILE: extensions/puterfs/fsentries/BaseOperation.js
================================================
import { TeePromise } from 'teepromise';
export default class BaseOperation {
static STATUS_PENDING = {};
static STATUS_RUNNING = {};
static STATUS_DONE = {};
/** @type {PromiseLike & { resolve: () => void }} */
#donePromise;
constructor () {
this.status_ = this.constructor.STATUS_PENDING;
this.#donePromise = new TeePromise();
}
get status () {
return this.status_;
}
set status (status) {
this.status_ = status;
if ( status === this.constructor.STATUS_DONE ) {
this.#donePromise.resolve();
}
}
async awaitDone () {
await this.#donePromise;
}
async onComplete (fn) {
await this.#donePromise;
fn();
}
}
================================================
FILE: extensions/puterfs/fsentries/Delete.js
================================================
import BaseOperation from './BaseOperation.js';
export default class extends BaseOperation {
constructor (uuid) {
super();
this.uuid = uuid;
}
getStatement () {
const statement = 'DELETE FROM fsentries WHERE uuid = ? LIMIT 1';
const values = [this.uuid];
return { statement, values };
}
apply (answer) {
answer.entry = null;
}
}
================================================
FILE: extensions/puterfs/fsentries/FSEntryController.js
================================================
import { TeePromise } from 'teepromise';
import BaseOperation from './BaseOperation.js';
import Delete from './Delete.js';
import Insert from './Insert.js';
import Update from './Update.js';
const { db } = extension.import('data');
const svc_params = extension.import('service:params');
const { PuterPath } = extension.import('fs');
const {
RootNodeSelector,
NodeChildSelector,
NodeUIDSelector,
NodePathSelector,
NodeInternalIDSelector,
} = extension.import('core').fs.selectors;
export default class FSEntryController {
static CONCERN = 'filesystem';
static STATUS_READY = {};
static STATUS_RUNNING_JOB = {};
constructor () {
this.status = FSEntryController.STATUS_READY;
this.currentState = {
queue: [],
updating_uuids: {},
};
this.deferredState = {
queue: [],
updating_uuids: {},
};
this.entryListeners_ = {};
this.mkPromiseForQueueSize_();
// this list of properties is for read operations
// (originally in FSEntryFetcher)
this.defaultProperties = [
'id',
'associated_app_id',
'uuid',
'public_token',
'bucket',
'bucket_region',
'file_request_token',
'user_id',
'parent_uid',
'is_dir',
'is_public',
'is_shortcut',
'is_symlink',
'symlink_path',
'shortcut_to',
'sort_by',
'sort_order',
'immutable',
'name',
'metadata',
'modified',
'created',
'accessed',
'size',
'layout',
'path',
];
this.subdomainProperties = [
'uuid',
'subdomain',
];
}
init () {
svc_params.createParameters('fsentry-service', [
{
id: 'max_queue',
description: 'Maximum queue size',
default: 50,
},
], this);
}
mkPromiseForQueueSize_ () {
this.queueSizePromise = new Promise((resolve, reject) => {
this.queueSizeResolve = resolve;
});
}
// #region write operations
async insert (entry) {
const op = new Insert(entry);
await this.enqueue_(op);
return op;
}
async update (uuid, entry) {
const op = new Update(uuid, entry);
await this.enqueue_(op);
return op;
}
async delete (uuid) {
const op = new Delete(uuid);
await this.enqueue_(op);
return op;
}
// #endregion
// #region read operations
async fast_get_descendants (uuid) {
return (await db.read(`
WITH RECURSIVE descendant_cte AS (
SELECT uuid, parent_uid
FROM fsentries
WHERE parent_uid = ?
UNION ALL
SELECT f.uuid, f.parent_uid
FROM fsentries f
INNER JOIN descendant_cte d ON f.parent_uid = d.uuid
)
SELECT uuid FROM descendant_cte
`, [uuid])).map(x => x.uuid);
}
async fast_get_direct_descendants (uuid) {
return (uuid === PuterPath.NULL_UUID
? await db.read('SELECT uuid FROM fsentries WHERE parent_uid IS NULL')
: await db.read(
'SELECT uuid FROM fsentries WHERE parent_uid = ?',
[uuid],
)).map(x => x.uuid);
}
waitForEntry (node, callback) {
// *** uncomment to debug slow waits ***
// console.log('ATTEMPT TO WAIT FOR', selector.describe())
let selector = node.get_selector_of_type(NodeUIDSelector);
if ( selector === null ) {
// console.log(new Error('========'));
return;
}
const entry_already_enqueued =
Object.prototype.hasOwnProperty.call(this.currentState.updating_uuids, selector.value) ||
Object.prototype.hasOwnProperty.call(this.deferredState.updating_uuids, selector.value) ;
if ( entry_already_enqueued ) {
callback();
return;
}
const k = `uid:${selector.value}`;
if ( ! Object.prototype.hasOwnProperty.call(this.entryListeners_, k) ) {
this.entryListeners_[k] = [];
}
const det = {
detach: () => {
const i = this.entryListeners_[k].indexOf(callback);
if ( i === -1 ) return;
this.entryListeners_[k].splice(i, 1);
if ( this.entryListeners_[k].length === 0 ) {
delete this.entryListeners_[k];
}
},
};
this.entryListeners_[k].push(callback);
return det;
}
async get (uuid, fetch_entry_options) {
const answer = {};
for ( const op of this.currentState.queue ) {
if ( op.uuid != uuid ) continue;
op.apply(answer);
}
for ( const op of this.deferredState.queue ) {
if ( op.uuid != uuid ) continue;
op.apply(answer);
op.apply(answer);
}
if ( answer.is_diff ) {
const base_entry = await this.find(
new NodeUIDSelector(uuid),
fetch_entry_options,
);
answer.entry = { ...base_entry, ...answer.entry };
}
return answer.entry;
}
/**
* Returns UUIDs of child fsentries under the specified
* parent fsentry
* @param {string} uuid - UUID of parent fsentry
* @returns fsentry[]
*/
async get_descendants (uuid) {
return uuid === PuterPath.NULL_UUID
? await db.read(
'SELECT uuid FROM fsentries WHERE parent_uid IS NULL',
[uuid],
)
: await db.read(
'SELECT uuid FROM fsentries WHERE parent_uid = ?',
[uuid],
)
;
}
/**
* Returns full fsentry nodes for entries under the specified
* parent fsentry
* @param {string} uuid - UUID of parent fsentry
* @returns fsentry[]
*/
async get_descendants_full (uuid, fetch_entry_options) {
const { thumbnail } = fetch_entry_options;
const columns = `${
[
...this.defaultProperties.map(v => `f.${v}`),
...this.subdomainProperties
.map(v => `s.${v} AS subdomain_${v}`),
].join(', ')
}${thumbnail ? ', thumbnail' : ''}`;
const results_with_dupes = uuid === PuterPath.NULL_UUID
? await db.read(
`SELECT ${columns} FROM fsentries WHERE parent_uid IS NULL`,
[uuid],
)
: await db.read(
`SELECT ${columns} FROM fsentries AS f ` +
'LEFT JOIN subdomains AS s ON f.id=s.root_dir_id ' +
'WHERE parent_uid = ? ORDER BY f.id',
[uuid],
)
;
const byId = new Map();
for ( const row of results_with_dupes ) {
const id = row.id;
let entry = byId.get(id);
if ( ! entry ) {
entry = { ...row };
if ( thumbnail ) entry.thumbnail = row.thumbnail;
entry.subdomains = [];
byId.set(id, entry);
}
if ( row.subdomain_uuid != null ) {
entry.subdomains.push({
uuid: row.subdomain_uuid,
subdomain: row.subdomain_subdomain,
});
}
}
return Array.from(byId.values());
}
async get_recursive_size (uuid) {
const cte_query = `
WITH RECURSIVE descendant_cte AS (
SELECT uuid, parent_uid, size
FROM fsentries
WHERE parent_uid = ?
UNION ALL
SELECT f.uuid, f.parent_uid, f.size
FROM fsentries f
INNER JOIN descendant_cte d
ON f.parent_uid = d.uuid
)
SELECT SUM(size) AS total_size FROM descendant_cte
`;
const rows = await db.read(cte_query, [uuid]);
return rows[0].total_size;
}
/**
* Finds a filesystem entry using the provided selector.
* @param {Object} selector - The selector object specifying how to find the entry
* @param {Object} fetch_entry_options - Options for fetching the entry
* @returns {Promise} The filesystem entry or null if not found
*/
async find (selector, fetch_entry_options) {
if ( selector instanceof RootNodeSelector ) {
return selector.entry;
}
if ( selector instanceof NodePathSelector ) {
return await this.findByPath(selector.value, fetch_entry_options);
}
if ( selector instanceof NodeUIDSelector ) {
return await this.findByUID(selector.value, fetch_entry_options);
}
if ( selector instanceof NodeInternalIDSelector ) {
return await this.findByID(selector.id, fetch_entry_options);
}
if ( selector instanceof NodeChildSelector ) {
let id;
if ( selector.parent instanceof RootNodeSelector ) {
id = await this.findNameInRoot(selector.name);
} else {
const parentEntry = await this.find(selector.parent);
if ( ! parentEntry ) return null;
id = await this.findNameInParent(parentEntry.uuid, selector.name);
}
if ( id === undefined ) return null;
if ( typeof id !== 'number' ) {
throw new Error(
'unexpected type for id value',
typeof id,
id,
);
}
return this.find(new NodeInternalIDSelector('mysql', id));
}
}
/**
* Finds a filesystem entry by its UUID.
* @param {string} uuid - The UUID of the entry to find
* @param {Object} fetch_entry_options - Options including thumbnail flag
* @returns {Promise} The filesystem entry or undefined if not found
*/
async findByUID (uuid, fetch_entry_options = {}) {
const { thumbnail } = fetch_entry_options;
let fsentry = await db.tryHardRead(
`SELECT ${
this.defaultProperties.join(', ')
}${thumbnail ? ', thumbnail' : ''
} FROM fsentries WHERE uuid = ? LIMIT 1`,
[uuid],
);
return fsentry[0];
}
/**
* Finds a filesystem entry by its internal database ID.
* @param {number} id - The internal ID of the entry to find
* @param {Object} fetch_entry_options - Options including thumbnail flag
* @returns {Promise} The filesystem entry or undefined if not found
*/
async findByID (id, fetch_entry_options = {}) {
const { thumbnail } = fetch_entry_options;
let fsentry = await db.tryHardRead(
`SELECT ${
this.defaultProperties.join(', ')
}${thumbnail ? ', thumbnail' : ''
} FROM fsentries WHERE id = ? LIMIT 1`,
[id],
);
return fsentry[0];
}
/**
* Finds a filesystem entry by its full path.
* @param {string} path - The full path of the entry to find
* @param {Object} fetch_entry_options - Options including thumbnail flag and tracer
* @returns {Promise} The filesystem entry or false if not found
*/
async findByPath (path, fetch_entry_options = {}) {
const { thumbnail } = fetch_entry_options;
if ( path === '/' ) {
return this.find(new RootNodeSelector());
}
const parts = path.split('/').filter(path => path !== '');
if ( parts.length === 0 ) {
// TODO: invalid path; this should be an error
return false;
}
// TODO: use a closure table for more efficient path resolving
let parent_uid = null;
let result;
const resultColsSql = this.defaultProperties.join(', ') +
(thumbnail ? ', thumbnail' : '');
result = await db.read(
`SELECT ${ resultColsSql
} FROM fsentries WHERE path=? LIMIT 1`,
[path],
);
// using knex instead
if ( result[0] ) return result[0];
const loop = async () => {
for ( let i = 0 ; i < parts.length ; i++ ) {
const part = parts[i];
const isLast = i == parts.length - 1;
const colsSql = isLast ? resultColsSql : 'uuid';
if ( parent_uid === null ) {
result = await db.read(
`SELECT ${ colsSql
} FROM fsentries WHERE parent_uid IS NULL AND name=? LIMIT 1`,
[part],
);
} else {
result = await db.read(
`SELECT ${ colsSql
} FROM fsentries WHERE parent_uid=? AND name=? LIMIT 1`,
[parent_uid, part],
);
}
if ( ! result[0] ) return false;
parent_uid = result[0].uuid;
}
};
if ( fetch_entry_options.tracer ) {
const tracer = fetch_entry_options.tracer;
const options = fetch_entry_options.trace_options;
await tracer.startActiveSpan(
'fs:sql:findByPath',
...(options ? [options] : []),
async span => {
await loop();
span.end();
},
);
} else {
await loop();
}
return result[0];
}
/**
* Finds the ID of a child entry with the given name in the root directory.
* @param {string} name - The name of the child entry to find
* @returns {Promise} The ID of the child entry or undefined if not found
*/
async findNameInRoot (name) {
let child_id = await db.read(
'SELECT `id` FROM `fsentries` WHERE `parent_uid` IS NULL AND name = ? LIMIT 1',
[name],
);
return child_id[0]?.id;
}
/**
* Finds the ID of a child entry with the given name under a specific parent.
* @param {string} parent_uid - The UUID of the parent directory
* @param {string} name - The name of the child entry to find
* @returns {Promise} The ID of the child entry or undefined if not found
*/
async findNameInParent (parent_uid, name) {
let child_id = await db.read(
'SELECT `id` FROM `fsentries` WHERE `parent_uid` = ? AND name = ? LIMIT 1',
[parent_uid, name],
);
return child_id[0]?.id;
}
/**
* Checks if an entry with the given name exists under a specific parent.
* @param {string} parent_uid - The UUID of the parent directory
* @param {string} name - The name to check for
* @returns {Promise} True if the name exists under the parent, false otherwise
*/
async nameExistsUnderParent (parent_uid, name) {
let check_dupe = await db.read(
'SELECT `id` FROM `fsentries` WHERE `parent_uid` = ? AND name = ? LIMIT 1',
[parent_uid, name],
);
return !!check_dupe[0];
}
/**
* Checks if an entry with the given name exists under a parent specified by ID.
* @param {number} parent_id - The internal ID of the parent directory
* @param {string} name - The name to check for
* @returns {Promise} True if the name exists under the parent, false otherwise
*/
async nameExistsUnderParentID (parent_id, name) {
const parent = await this.findByID(parent_id);
if ( ! parent ) {
return false;
}
return this.nameExistsUnderParent(parent.uuid, name);
}
// #endregion
// #region queue logic
async enqueue_ (op) {
const tp = new TeePromise();
while (
this.currentState.queue.length > this.max_queue ||
this.deferredState.queue.length > this.max_queue
) {
await this.queueSizePromise;
}
if ( ! (op instanceof BaseOperation) ) {
throw new Error('Invalid operation');
}
const state = this.status === FSEntryController.STATUS_READY ?
this.currentState : this.deferredState;
if ( ! Object.prototype.hasOwnProperty.call(state.updating_uuids, op.uuid) ) {
state.updating_uuids[op.uuid] = [];
}
state.updating_uuids[op.uuid].push(state.queue.length);
state.queue.push(op);
// DRY: same pattern as FSOperationContext:provideValue
// DRY: same pattern as FSOperationContext:rejectValue
if ( Object.prototype.hasOwnProperty.call(this.entryListeners_, op.uuid) ) {
const listeners = this.entryListeners_[op.uuid];
delete this.entryListeners_[op.uuid];
for ( const lis of listeners ) lis();
}
this.checkShouldExec_();
await op.awaitDone();
}
checkShouldExec_ () {
if ( this.status !== FSEntryController.STATUS_READY ) return;
if ( this.currentState.queue.length === 0 ) return;
this.exec_();
}
async exec_ () {
if ( this.status !== FSEntryController.STATUS_READY ) {
throw new Error('Duplicate exec_ call');
}
const queue = this.currentState.queue;
this.status = FSEntryController.STATUS_RUNNING_JOB;
// const conn = await db_primary.promise().getConnection();
// await conn.beginTransaction();
for ( const op of queue ) {
op.status = op.constructor.STATUS_RUNNING;
// await conn.execute(stmt, values);
}
// await conn.commit();
// conn.release();
// const stmtAndVals = queue.map(op => op.getStatementAndValues());
// const stmts = stmtAndVals.map(x => x.stmt).join('; ');
// const vals = stmtAndVals.reduce((acc, x) => acc.concat(x.values), []);
// *** uncomment to debug batch queries ***
// this.log.debug({ stmts, vals });
// console.log('<<========================');
// console.log({ stmts, vals });
// console.log('>>========================');
// this.log.debug('array?', Array.isArray(vals))
await db.batch_write(queue.map(op => op.getStatement()));
for ( const op of queue ) {
op.status = op.constructor.STATUS_DONE;
}
this.flipState_();
this.status = FSEntryController.STATUS_READY;
for ( const op of queue ) {
op.status = op.constructor.STATUS_DONE;
}
this.checkShouldExec_();
}
flipState_ () {
this.currentState = this.deferredState;
this.deferredState = {
queue: [],
updating_uuids: {},
};
const queueSizeResolve = this.queueSizeResolve;
this.mkPromiseForQueueSize_();
queueSizeResolve();
}
// #endregion
}
================================================
FILE: extensions/puterfs/fsentries/Insert.js
================================================
import { safeHasOwnProperty } from '../lib/objectfn.js';
import BaseOperation from './BaseOperation.js';
export default class extends BaseOperation {
static requiredForCreate = [
'uuid',
'parent_uid',
];
static allowedForCreate = [
...this.requiredForCreate,
'name',
'user_id',
'is_dir',
'created',
'modified',
'immutable',
'shortcut_to',
'is_shortcut',
'metadata',
'bucket',
'bucket_region',
'thumbnail',
'accessed',
'size',
'symlink_path',
'is_symlink',
'associated_app_id',
'path',
];
constructor (entry) {
super();
const requiredForCreate = this.constructor.requiredForCreate;
const allowedForCreate = this.constructor.allowedForCreate;
{
const sanitized_entry = {};
for ( const k of allowedForCreate ) {
if ( safeHasOwnProperty(entry, k) ) {
sanitized_entry[k] = entry[k];
}
}
entry = sanitized_entry;
}
for ( const k of requiredForCreate ) {
if ( ! safeHasOwnProperty(entry, k) ) {
throw new Error(`Missing required property: ${k}`);
}
}
this.entry = entry;
}
getStatement () {
const fields = Object.keys(this.entry);
const statement = 'INSERT INTO fsentries ' +
`(${fields.join(', ')}) ` +
`VALUES (${fields.map(() => '?').join(', ')})`;
const values = fields.map(k => this.entry[k]);
return { statement, values };
}
apply (answer) {
answer.entry = { ...this.entry };
}
get uuid () {
return this.entry.uuid;
}
};
================================================
FILE: extensions/puterfs/fsentries/Update.js
================================================
import { safeHasOwnProperty } from '../lib/objectfn.js';
import BaseOperation from './BaseOperation.js';
export default class extends BaseOperation {
static allowedForUpdate = [
'name',
'parent_uid',
'user_id',
'modified',
'shortcut_to',
'metadata',
'thumbnail',
'size',
'path',
];
constructor (uuid, entry) {
super();
const allowedForUpdate = this.constructor.allowedForUpdate;
{
const sanitized_entry = {};
for ( const k of allowedForUpdate ) {
if ( safeHasOwnProperty(entry, k) ) {
sanitized_entry[k] = entry[k];
}
}
entry = sanitized_entry;
}
this.uuid = uuid;
this.entry = entry;
}
getStatement () {
const fields = Object.keys(this.entry);
const statement = 'UPDATE fsentries SET ' +
`${fields.map(k => `${k} = ?`).join(', ')} ` +
'WHERE uuid = ? LIMIT 1';
const values = fields.map(k => this.entry[k]);
values.push(this.uuid);
return { statement, values };
}
apply (answer) {
if ( ! answer.entry ) {
answer.is_diff = true;
answer.entry = {};
}
Object.assign(answer.entry, this.entry);
}
};
================================================
FILE: extensions/puterfs/lib/objectfn.js
================================================
/**
* Instead of `myObject.hasOwnProperty(k)`, always write:
* `safeHasOwnProperty(myObject, k)`.
*
* This is a less verbose way to call `Object.prototype.hasOwnProperty.call`.
* This prevents unexpected behavior when `hasOwnProperty` is overridden,
* which is especially possible for objects parsed from user-sent JSON.
*
* explanation: https://eslint.org/docs/latest/rules/no-prototype-builtins
* @param {*} o
* @param {...any} a
* @returns
*/
export const safeHasOwnProperty = (o, ...a) => {
return Object.prototype.hasOwnProperty.call(o, ...a);
};
================================================
FILE: extensions/puterfs/main.js
================================================
/*
* 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 .
*/
import FSEntryController from './fsentries/FSEntryController.js';
import PuterFSProvider from './PuterFSProvider.js';
import LocalDiskStorageController from './storage/LocalDiskStorageController.js';
import ProxyStorageController from './storage/ProxyStorageController.js';
const svc_event = extension.import('service:event');
const fsEntryController = new FSEntryController();
const storageController = new ProxyStorageController();
extension.on('init', async () => {
fsEntryController.init();
// Keep track of possible storage strategies for puterfs here
let defaultStorage = 'flat-files';
const storageStrategies = {
'flat-files': new LocalDiskStorageController(),
};
// Emit the "create storage strategies" event
const event = {
createStorageStrategy (name, implementation) {
storageStrategies[name] = implementation;
if ( implementation === undefined ) {
throw new Error('createStorageStrategy was called wrong');
}
if ( implementation.forceDefault ) {
defaultStorage = name;
}
},
};
// Awaiting the event ensures all the storage strategies are registered
await svc_event.emit('puterfs.storage.create', event);
let configuredStorage = defaultStorage;
if ( config.storage ) configuredStorage = config.storage;
// Not we can select the configured strategy
const storageToUse = storageStrategies[configuredStorage];
storageController.setDelegate(storageToUse);
// The StorageController may need to await some asynchronous operations
// before it's ready to be used.
await storageController.init();
});
extension.on('create.filesystem-types', event => {
event.createFilesystemType('puterfs', {
mount ({ path }) {
return new PuterFSProvider({
fsEntryController,
storageController,
});
},
});
});
================================================
FILE: extensions/puterfs/package.json
================================================
{
"main": "main.js",
"type": "module",
"dependencies": {
"teepromise": "^0.1.1",
"uuid": "^13.0.0"
}
}
================================================
FILE: extensions/puterfs/storage/LocalDiskStorageController.js
================================================
import fs from 'node:fs';
import path_ from 'node:path';
import { TeePromise } from 'teepromise';
const {
progress_stream,
size_limit_stream,
} = extension.import('core').util.streamutil;
export default class LocalDiskStorageController {
constructor () {
this.path = path_.join(process.cwd(), '/storage');
}
async init () {
await fs.promises.mkdir(this.path, { recursive: true });
}
async upload ({ uid, file, storage_api }) {
const { progress_tracker } = storage_api;
if ( file.buffer ) {
const path = this.#getPath(uid);
await fs.promises.writeFile(path, file.buffer);
progress_tracker.set_total(file.buffer.length);
progress_tracker.set(file.buffer.length);
return;
}
let stream = file.stream;
stream = progress_stream(stream, {
total: file.size,
progress_callback: evt => {
progress_tracker.set_total(file.size);
progress_tracker.set(evt.uploaded);
},
});
stream = size_limit_stream(stream, {
limit: file.size,
});
const writePromise = new TeePromise();
const path = this.#getPath(uid);
const write_stream = fs.createWriteStream(path);
write_stream.on('error', () => writePromise.reject());
write_stream.on('finish', () => writePromise.resolve());
stream.pipe(write_stream);
// @ts-ignore (it's wrong about this)
await writePromise;
}
copy () {
}
delete () {
}
read () {
}
#getPath (key) {
return path_.join(this.path, key);
}
}
================================================
FILE: extensions/puterfs/storage/ProxyStorageController.js
================================================
export default class {
constructor (delegate) {
this.delegate = delegate ?? null;
}
setDelegate (delegate) {
this.delegate = delegate;
}
init (...a) {
return this.delegate.init(...a);
}
upload (...a) {
return this.delegate.upload(...a);
}
copy (...a) {
return this.delegate.copy(...a);
}
delete (...a) {
return this.delegate.delete(...a);
}
read (...a) {
return this.delegate.read(...a);
}
}
================================================
FILE: extensions/serverInfo/config.json
================================================
{
"allowedUsernames": [
"puter"
]
}
================================================
FILE: extensions/serverInfo/index.ts
================================================
/* global config, extension */
import fs from 'fs/promises';
import os from 'os';
import type {
ExtensionRequest,
ExtensionResponse,
} from '../api.d.ts';
const { Controller, Get, ExtensionController } = extension.import('extensionController');
@Controller('/serverInfo', [...config.allowedUsernames])
class ServerInfoController extends ExtensionController {
@Get('', { subdomain: 'api' })
async getServerInfo (_req: ExtensionRequest, res: ExtensionResponse) {
const osData = {
platform: os.platform(),
type: os.type(),
release: os.release(),
pretty: `${os.type()} ${os.release()}`,
};
const cpus = os.cpus();
const cpuData = {
model: cpus[0]?.model || 'Unknown',
cores: cpus.length,
};
const ramData = {
total: os.totalmem(),
free: os.freemem(),
totalGB: (os.totalmem() / 1073741824).toFixed(2),
freeGB: (os.freemem() / 1073741824).toFixed(2),
};
const uptimeSeconds = os.uptime();
const uptimeData = {
seconds: uptimeSeconds,
days: Math.floor(uptimeSeconds / 86400),
hours: Math.floor((uptimeSeconds % 86400) / 3600),
minutes: Math.floor((uptimeSeconds % 3600) / 60),
pretty: `${Math.floor(uptimeSeconds / 86400)}d ${Math.floor((uptimeSeconds % 86400) / 3600)}h ${Math.floor((uptimeSeconds % 3600) / 60)}m`,
};
let diskData = { total: 'N/A', free: 'N/A', used: 'N/A' };
try {
const stats = await fs.statfs('/');
const totalGB = (stats.blocks * stats.bsize / 1073741824);
const freeGB = (stats.bfree * stats.bsize / 1073741824);
const usedGB = (totalGB - freeGB).toFixed(2);
diskData = { total: totalGB.toFixed(2), free: freeGB.toFixed(2), used: usedGB };
} catch ( err ) {
console.error('Disk stats error:', err);
}
const response = {
os: osData,
cpu: cpuData,
ram: ramData,
uptime: uptimeData,
disk: diskData,
loadavg: os.loadavg(),
hostname: os.hostname(),
};
res.json(response);
}
}
(new ServerInfoController()).registerRoutes();
================================================
FILE: extensions/serverInfo/package.json
================================================
{
"name": "@heyputer/server-info-extension",
"main": "index.js",
"type": "module",
"scripts": {
"postinstall": "tsc --noCheck"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}
================================================
FILE: extensions/serverInfo/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2024",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"sourceMap": true,
"noEmitOnError": true,
"noImplicitAny": false,
"allowJs": true,
"checkJs": false,
},
"include": [
"./**/*.ts",
"./**/*.d.ts"
],
"exclude": [
"**/*.test.ts",
"**/*.spec.ts",
"**/test/**",
"**/tests/**",
"node_modules",
"dist",
"*.js"
]
}
================================================
FILE: extensions/serverInfo/types.ts
================================================
import '../api.js';
================================================
FILE: extensions/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2024",
"module": "node16",
"moduleResolution": "node16",
"allowJs": true,
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"sourceMap": true,
},
"include": [
"./**/*.ts",
"./**/*.d.ts",
"./**/*.d.mts",
"./**/*.d.cts"
],
"exclude": [
"**/*.test.ts",
"**/*.spec.ts",
"**/test/**",
"**/tests/**",
"node_modules",
"dist"
]
}
================================================
FILE: extensions/utilities.js
================================================
//@extension priority -10000
extension.exports = {};
extension.exports.sleep = async (seconds) => {
await new Promise(resolve => {
setTimeout(resolve, seconds);
});
};
================================================
FILE: extensions/whoami/main.js
================================================
import './routes.js';
================================================
FILE: extensions/whoami/package.json
================================================
{
"name": "@heyputer/extension-whoami",
"main": "main.js",
"type": "module",
"dependencies": {
"javascript-time-ago": "^2.5.12"
}
}
================================================
FILE: extensions/whoami/routes.js
================================================
// static imports
import _path from 'fs';
import TimeAgo from 'javascript-time-ago';
import localeEn from 'javascript-time-ago/locale/en';
// runtime imports
const { UserActorType, AppUnderUserActorType } = extension.import('core');
const {
id2uuid,
get_descendants,
suggest_app_for_fsentry,
is_shared_with_anyone,
get_app,
get_taskbar_items,
} = extension.import('core').util.helpers;
const timeago = (() => {
TimeAgo.addDefaultLocale(localeEn);
return new TimeAgo('en-US');
})();
const whoami_common = ({ is_user, user }) => {
const details = {};
// User's immutable default (often called "system") directories'
// alternative (to path) identifiers are sent to the user's client
// (but not to apps; they don't need this information)
if ( is_user ) {
const directories = details.directories = {};
const name_to_path = {
'desktop_uuid': `/${user.username}/Desktop`,
'appdata_uuid': `/${user.username}/AppData`,
'documents_uuid': `/${user.username}/Documents`,
'pictures_uuid': `/${user.username}/Pictures`,
'videos_uuid': `/${user.username}/Videos`,
'trash_uuid': `/${user.username}/Trash`,
};
for ( const k in name_to_path ) {
directories[name_to_path[k]] = user[k];
}
}
if ( user.last_activity_ts ) {
// Create a Date object and get the epoch timestamp
let epoch;
try {
epoch = new Date(user.last_activity_ts).getTime();
// round to 1 decimal place
epoch = Math.round(epoch / 1000);
} catch ( e ) {
console.error('Error parsing last_activity_ts', e);
}
// add last_activity_ts
details.last_activity_ts = epoch;
}
return details;
};
extension.get('/whoami', { subdomain: 'api' }, async (req, res, next) => {
const actor = req.actor;
if ( ! actor ) {
throw Error('actor not found in context');
}
const is_user = actor.type instanceof UserActorType;
if ( req.query.icon_size ) {
const ALLOWED_SIZES = ['16', '32', '64', '128', '256', '512'];
if ( ! ALLOWED_SIZES.includes(req.query.icon_size) ) {
res.status(400).send({ error: 'Invalid icon_size' });
}
}
const oidc_only = req.user.password === null;
const details = {
username: req.user.username,
uuid: req.user.uuid,
email: req.user.email,
unconfirmed_email: req.user.email,
email_confirmed: req.user.email_confirmed
|| req.user.username === 'admin',
requires_email_confirmation: req.user.requires_email_confirmation,
desktop_bg_url: req.user.desktop_bg_url,
desktop_bg_color: req.user.desktop_bg_color,
desktop_bg_fit: req.user.desktop_bg_fit,
is_temp: (req.user.password === null && req.user.email === null),
oidc_only,
...(oidc_only ? await (async () => {
try {
const svc_oidc = req.services.get('oidc');
const providers = await svc_oidc.getEnabledProviderIds();
const origin = (svc_oidc.global_config?.origin || '').replace(/\/$/, '');
const provider = providers && providers[0];
if ( provider ) {
return {
oidc_revalidate_url: `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_id=${req.user.id}`,
};
}
return {};
} catch ( _e ) {
return {};
}
})() : {}),
taskbar_items: await get_taskbar_items(req.user, {
...(req.query.icon_size
? { icon_size: req.query.icon_size }
: { no_icons: true }),
}),
referral_code: req.user.referral_code,
otp: !!req.user.otp_enabled,
human_readable_age: timeago.format(new Date(req.user.timestamp)),
hasDevAccountAccess: !!req.actor.type.user.metadata?.hasDevAccountAccess,
...(req.new_token ? { token: req.token } : {}),
is_user_token: true, // gets deleted if not a user token
};
// TODO: redundant? GetUserService already puts these values on 'user'
// Get whoami values from other services
const /** @type {any} */ svc_whoami = req.services.get('whoami');
const /** @type {any} */ svc_permission = req.services.get('permission');
const provider_details = await svc_whoami.get_details({
user: req.user,
actor: actor,
});
Object.assign(details, provider_details);
if ( ! is_user ) {
// When apps call /whoami they should not see these attributes
// delete details.username;
// delete details.uuid;
if ( ! (await svc_permission.check(actor, `user:${details.uuid}:email:read`, { no_cache: true })) ) {
delete details.email;
delete details.unconfirmed_email;
}
delete details.desktop_bg_url;
delete details.desktop_bg_color;
delete details.desktop_bg_fit;
delete details.taskbar_items;
delete details.token;
delete details.human_readable_age;
delete details.is_user_token;
}
if ( actor.type instanceof AppUnderUserActorType ) {
details.app_name = actor.type.app.name;
// IDEA: maybe we do this in the future
// details.app = {
// name: actor.type.app.name,
// };
}
Object.assign(details, whoami_common({ is_user, user: req.user }));
res.send(details);
});
extension.post('/whoami', { subdomain: 'api' }, async (req, res) => {
const actor = req.actor;
if ( ! actor ) {
throw Error('actor not found in context');
}
const is_user = actor.type instanceof UserActorType;
if ( ! is_user ) {
throw Error('actor is not a user');
}
let desktop_items = [];
// check if user asked for desktop items
if ( req.query.return_desktop_items === 1 || req.query.return_desktop_items === '1' || req.query.return_desktop_items === 'true' ) {
// by cached desktop id
if ( req.user.desktop_id ) {
// TODO: Check if used anywhere, maybe remove
// eslint-disable-next-line no-undef
desktop_items = await db.read(`SELECT * FROM fsentries
WHERE user_id = ? AND parent_uid = ?`,
[req.user.id, await id2uuid(req.user.desktop_id)]);
}
// by desktop path
else {
desktop_items = await get_descendants(`${req.user.username }/Desktop`, req.user, 1, true);
}
// clean up desktop items and add some extra information
if ( desktop_items.length > 0 ) {
if ( desktop_items.length > 0 ) {
for ( let i = 0; i < desktop_items.length; i++ ) {
if ( desktop_items[i].id !== null ) {
// suggested_apps for files
if ( ! desktop_items[i].is_dir ) {
desktop_items[i].suggested_apps = await suggest_app_for_fsentry(desktop_items[i], { user: req.user });
}
// is_shared
desktop_items[i].is_shared = await is_shared_with_anyone(desktop_items[i].id);
// associated_app
if ( desktop_items[i].associated_app_id ) {
const app = await get_app({ id: desktop_items[i].associated_app_id });
// remove some privileged information
delete app.id;
delete app.approved_for_listing;
delete app.approved_for_opening_items;
delete app.godmode;
delete app.owner_user_id;
// add to array
desktop_items[i].associated_app = app;
} else {
desktop_items[i].associated_app = {};
}
// remove associated_app_id since it's sensitive info
// delete desktop_items[i].associated_app_id;
}
// id is sesitive info
delete desktop_items[i].id;
delete desktop_items[i].user_id;
delete desktop_items[i].bucket;
desktop_items[i].path = _path.join('/', req.user.username, desktop_items[i].name);
}
}
}
}
const oidc_only = req.user.password === null;
// send user object
res.send(Object.assign({
username: req.user.username,
uuid: req.user.uuid,
email: req.user.email,
email_confirmed: req.user.email_confirmed
|| req.user.username === 'admin',
requires_email_confirmation: req.user.requires_email_confirmation,
desktop_bg_url: req.user.desktop_bg_url,
desktop_bg_color: req.user.desktop_bg_color,
desktop_bg_fit: req.user.desktop_bg_fit,
is_temp: (req.user.password === null && req.user.email === null),
oidc_only,
taskbar_items: await get_taskbar_items(req.user),
desktop_items: desktop_items,
referral_code: req.user.referral_code,
hasDevAccountAccess: !!req.actor.user.metadata?.hasDevAccountAccess,
}, whoami_common({ is_user, user: req.user })));
});
================================================
FILE: extensions/worker-sandbox.js
================================================
const page = `
Puter Worker Sandbox Playground
Puter Worker Sandbox Playground
Use this page to interact with the puter APIs in the same sandbox as your worker.
Run
Clear Logs
`;
extension.get('/', { noauth: true, subdomain: 'worker-sandbox' }, (req, res) => {
res.type('html').send(page);
});
================================================
FILE: install.md
================================================
# INSTALL.md
## Node.js & npm Installation Guide
## 1. Arch Linux / Manjaro
```bash
# Update package database
sudo pacman -Syu
# Install Node.js and npm
sudo pacman -S nodejs npm
# Verify installation
node -v
npm -v
````
---
## 2. Debian / Ubuntu
```bash
# Update package database and install curl
sudo apt update
sudo apt install -y curl
# Install nvm (Node Version Manager)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
# Load nvm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# Reload shell
source ~/.bashrc # Or source ~/.zshrc if using Zsh
# Install latest Node.js and npm
nvm install node
# Verify installation
node -v
npm -v
```
---
## 3. CentOS / RHEL
```bash
# Install curl if missing
sudo yum install -y curl
# Install nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
# Load nvm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# Reload shell
source ~/.bashrc # Or source ~/.zshrc if using Zsh
# Install latest Node.js and npm
nvm install node
# Verify installation
node -v
npm -v
```
---
## 4. Fedora
```bash
# Update system
sudo dnf update -y
# Install Node.js and npm from modules
sudo dnf module list nodejs # Check available versions
sudo dnf module enable nodejs:18 # Example: enable Node 18 LTS
sudo dnf install -y nodejs npm
# Verify installation
node -v
npm -v
```
---
## 5. openSUSE
```bash
# Refresh repositories
sudo zypper refresh
# Install Node.js and npm
sudo zypper install -y nodejs npm
# Verify installation
node -v
npm -v
```
---
## 6. Using nvm (Optional, Recommended)
`nvm` allows installing multiple Node.js versions and switching between them easily:
```bash
# Install nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
# Load nvm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# Reload shell
source ~/.bashrc # Or source ~/.zshrc if using Zsh
# Install latest Node.js and npm
nvm install node
# Or install latest LTS version
nvm install --lts
# Switch Node versions
nvm use node
# Verify installation
node -v
npm -v
```
================================================
FILE: mod_packages/testex/package.json
================================================
{}
================================================
FILE: mods/README.md
================================================
# Puter Mods
A list of Puter mods which may be expanded in the future.
**Contributions of new mods are welcome.**
## kdmod
- **location:** [./kdmod](./kdmod)
- **description:**
> "kernel dev mod"; specifically for the devex needs of
> GitHub user KernelDeimos and provided in case anyone else
> finds it of any use.
================================================
FILE: mods/mods_available/dev-socket/main.js
================================================
/*
* 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 .
*/
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
const SOCKET_NAME = 'dev.sock';
const WELCOME = [
'Puter dev socket – enter a command (e.g. help) and press Enter.',
'Close the connection with Ctrl+C or by typing exit.',
'',
].join('\n');
function getSocketDir () {
if ( process.env.PUTER_DEV_SOCKET_DIR ) {
return process.env.PUTER_DEV_SOCKET_DIR;
}
const volatileRuntime = path.join(process.cwd(), 'volatile', 'runtime');
if ( fs.existsSync(volatileRuntime) ) {
return volatileRuntime;
}
return process.cwd();
}
extension.on('init', async () => {
if ( process.env.DEVCONSOLE !== '1' ) {
return;
}
const commands = extension.import('service:commands');
const socketDir = getSocketDir();
const socketPath = path.join(socketDir, SOCKET_NAME);
try {
if ( fs.existsSync(socketPath) ) {
fs.unlinkSync(socketPath);
}
fs.mkdirSync(socketDir, { recursive: true });
} catch ( err ) {
console.warn('dev-socket: could not prepare socket path', socketPath, err.message);
return;
}
const server = net.createServer((socket) => {
socket.setEncoding('utf8');
socket.write(`${WELCOME }\n> `);
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk;
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? '';
for ( const line of lines ) {
const trimmed = line.trim();
if ( trimmed === '' ) continue;
if ( trimmed.toLowerCase() === 'exit' ) {
socket.end();
return;
}
const log = {
log: (msg) => {
socket.write(`${String(msg) }\n`);
},
error: (msg) => {
socket.write(`${String(msg) }\n`);
},
};
commands.executeRawCommand(trimmed, log).then(() => {
socket.write('> ');
}).catch((err) => {
log.error(err?.message ?? err);
socket.write('> ');
});
}
});
socket.on('end', () => {
});
socket.on('error', () => {
});
});
server.listen(socketPath, () => {
console.log('dev-socket: socket listening at', socketPath);
});
server.on('error', (err) => {
console.warn('dev-socket: socket error', err.message);
});
});
================================================
FILE: mods/mods_available/dev-socket/package.json
================================================
{
"name": "@heyputer/extension-dev-console",
"version": "1.0.0",
"description": "Dev socket for running backend commands locally (opt-in via DEVCONSOLE=1)",
"main": "main.js",
"type": "module",
"private": true
}
================================================
FILE: mods/mods_available/example/main.js
================================================
/*
* 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 .
*/
extension.get('/example-mod-get', (req, res) => {
res.send('Hello World!');
});
extension.on('install', ({ services }) => {
// console.log('install was called');
});
================================================
FILE: mods/mods_available/example/package.json
================================================
{
"name": "example-puter-extension",
"version": "1.0.0",
"description": "",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "AGPL-3.0-only"
}
================================================
FILE: mods/mods_available/example-singlefile.js
================================================
/*
* 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 .
*/
extension.get('/example-onefile-get', (req, res) => {
res.send('Hello World!');
});
extension.on('install', ({ services }) => {
// console.log('install was called');
});
================================================
FILE: mods/mods_available/kdmod/CustomPuterService.js
================================================
/*
* 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 .
*/
const path = require('path');
class CustomPuterService extends use.Service {
async _init () {
const svc_commands = this.services.get('commands');
this._register_commands(svc_commands);
const svc_puterHomepage = this.services.get('puter-homepage');
svc_puterHomepage.register_script('/custom-gui/main.js');
}
['__on_install.routes'] (_, { app }) {
const require = this.require;
const express = require('express');
const path_ = require('path');
app.use('/custom-gui',
express.static(path.join(__dirname, 'gui')));
}
async ['__on_boot.consolidation'] () {
const then = Date.now();
this.tod_widget = () => {
const s = 5 - Math.floor((Date.now() - then) / 1000);
const lines = [
'\x1B[36;1mKDMOD ENABLED\x1B[0m' +
` (👁️ ${s}s)`,
];
// It would be super cool to be able to use this here
// surrounding_box('33;1', lines);
return lines;
};
const svc_devConsole = this.services.get('dev-console', { optional: true });
if ( ! svc_devConsole ) return;
svc_devConsole.add_widget(this.tod_widget);
setTimeout(() => {
svc_devConsole.remove_widget(this.tod_widget);
}, 5000);
}
_register_commands (commands) {
commands.registerCommands('o', [
{
id: 'k',
description: '',
handler: async (_, log) => {
const svc_devConsole = this.services.get('dev-console', { optional: true });
if ( ! svc_devConsole ) return;
svc_devConsole.remove_widget(this.tod_widget);
const lines = this.tod_widget();
for ( const line of lines ) log.log(line);
this.tod_widget = null;
},
},
]);
}
}
module.exports = { CustomPuterService };
================================================
FILE: mods/mods_available/kdmod/README.md
================================================
# Kernel Dev Mod
This mod makes testing and debugging easier.
## Current Features:
- A service-script adds `reqex` to the `window` object in the client,
which contains a bunch of example requests to internal API endpoints.
================================================
FILE: mods/mods_available/kdmod/ShareTestService.js
================================================
/*
* 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 .
*/
// TODO: accessing these imports directly from a mod is not really
// the way mods are intended to work; this is temporary until
// we have these things registered in "useapi".
const {
get_user,
invalidate_cached_user,
deleteUser,
} = require('../../../src/backend/src/helpers.js');
const { HLWrite } = require('../../../src/backend/src/filesystem/hl_operations/hl_write.js');
const { LLRead } = require('../../../src/backend/src/filesystem/ll_operations/ll_read.js');
const { Actor, UserActorType }
= require('../../../src/backend/src/services/auth/Actor.js');
const { DB_WRITE } = require('../../../src/backend/src/services/database/consts.js');
const {
RootNodeSelector,
NodeChildSelector,
NodePathSelector,
} = require('../../../src/backend/src/filesystem/node/selectors.js');
const { Context } = require('../../../src/backend/src/util/context.js');
class ShareTestService extends use.Service {
static MODULES = {
uuidv4: require('uuid').v4,
};
async _init () {
const svc_commands = this.services.get('commands');
this._register_commands(svc_commands);
this.scenarios = require('./data/sharetest_scenarios');
const svc_db = this.services.get('database');
this.db = svc_db.get(svc_db.DB_WRITE, 'share-test');
}
_register_commands (commands) {
commands.registerCommands('share-test', [
{
id: 'start',
description: '',
handler: async (_, log) => {
const results = await this.runit();
for ( const result of results ) {
log.log(`=== ${result.title} ===`);
if ( ! result.report ) {
log.log('\x1B[32;1mSUCCESS\x1B[0m');
continue;
}
log.log('\x1B[31;1mSTOPPED\x1B[0m at ' +
`${result.report.step}: ${
result.report.report.message}`);
}
},
},
]);
}
async runit () {
await this.teardown_();
await this.setup_();
const results = [];
for ( const scenario of this.scenarios ) {
if ( ! scenario.title ) {
scenario.title = scenario.sequence.map(step => step.title).join('; ');
}
results.push({
title: scenario.title,
report: await this.run_scenario_(scenario),
});
}
await this.teardown_();
return results;
}
async setup_ () {
await this.create_test_user_('testuser_eric');
await this.create_test_user_('testuser_stan');
await this.create_test_user_('testuser_kyle');
await this.create_test_user_('testuser_kenny');
}
async run_scenario_ (scenario) {
let error;
// Run sequence
for ( const step of scenario.sequence ) {
const method = this[`__scenario:${step.call}`];
const user = await get_user({ username: step.as });
const actor = await Actor.create(UserActorType, { user });
const generated = { user, actor };
const report = await Context.get().sub({ user, actor })
.arun(async () => {
return await method.call(this, generated, step.with);
});
if ( report ) {
error = { step: step.title, report };
break;
}
}
return error;
}
async teardown_ () {
await this.delete_test_user_('testuser_eric');
await this.delete_test_user_('testuser_stan');
await this.delete_test_user_('testuser_kyle');
await this.delete_test_user_('testuser_kenny');
}
async create_test_user_ (username) {
await this.db.write(`
INSERT INTO user (uuid, username, email, free_storage, password)
VALUES (?, ?, ?, ?, ?)
`,
[
this.modules.uuidv4(),
username,
`${username}@example.com`,
1024 * 1024 * 500, // 500 MiB
this.modules.uuidv4(),
]);
const user = await get_user({ username });
const svc_user = this.services.get('user');
await svc_user.generate_default_fsentries({ user });
invalidate_cached_user(user);
return user;
}
async delete_test_user_ (username) {
const user = await get_user({ username });
if ( ! user ) return;
await deleteUser(user.id);
}
// API for scenarios
async ['__scenario:create-example-file'] (
{ actor, user },
{ name, contents },
) {
const svc_fs = this.services.get('filesystem');
const parent = await svc_fs.node(new NodePathSelector(`/${user.username}/Desktop`));
console.log('test -> create-example-file',
user,
name,
contents);
const buffer = Buffer.from(contents);
const file = {
size: buffer.length,
name: name,
type: 'application/octet-stream',
buffer,
};
const hl_write = new HLWrite();
await hl_write.run({
actor,
user,
destination_or_parent: parent,
specified_name: name,
file,
});
}
async ['__scenario:assert-no-access'] (
{ actor, user },
{ path },
) {
const svc_fs = this.services.get('filesystem');
const node = await svc_fs.node(new NodePathSelector(path));
const ll_read = new LLRead();
let expected_e; try {
const stream = await ll_read.run({
fsNode: node,
actor,
});
} catch (e) {
expected_e = e;
}
if ( ! expected_e ) {
return { message: 'expected error, got none' };
}
}
async ['__scenario:grant'] (
{ actor, user },
{ to, permission },
) {
const svc_permission = this.services.get('permission');
await svc_permission.grant_user_user_permission(actor, to, permission, {}, {});
}
async ['__scenario:assert-access'] (
{ actor, user },
{ path, level },
) {
const svc_fs = this.services.get('filesystem');
const svc_acl = this.services.get('acl');
const node = await svc_fs.node(new NodePathSelector(path));
const has_read = await svc_acl.check(actor, node, 'read');
const has_write = await svc_acl.check(actor, node, 'write');
if ( level !== 'write' && level !== 'read' ) {
return {
message: 'unexpected value for "level" parameter',
};
}
if ( level === 'read' && has_write ) {
return {
message: 'expected read-only but actor can write',
};
}
if ( level === 'read' && !has_read ) {
return {
message: 'expected read access but no read access',
};
}
if ( level === 'write' && (!has_write || !has_read) ) {
return {
message: 'expected write access but no write access',
};
}
if ( level === 'manage' && (!has_write || !has_read) ) {
return {
message: 'expected write access but no write access',
};
}
}
}
module.exports = {
ShareTestService,
};
================================================
FILE: mods/mods_available/kdmod/data/sharetest_scenarios.js
================================================
/*
* 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 .
*/
module.exports = [
{
sequence: [
{
title: 'Kyle creates a file',
call: 'create-example-file',
as: 'testuser_kyle',
with: {
name: 'example.txt',
contents: 'secret file',
},
},
{
title: 'Eric tries to access it',
call: 'assert-no-access',
as: 'testuser_eric',
with: {
path: '/testuser_kyle/Desktop/example.txt',
},
},
],
},
{
sequence: [
{
title: 'Stan creates a file',
call: 'create-example-file',
as: 'testuser_stan',
with: {
name: 'example.txt',
contents: 'secret file',
},
},
{
title: 'Stan grants permission to Eric',
call: 'grant',
as: 'testuser_stan',
with: {
to: 'testuser_eric',
permission: 'fs:/testuser_stan/Desktop/example.txt:read',
},
},
{
title: 'Eric tries to access it',
call: 'assert-access',
as: 'testuser_eric',
with: {
path: '/testuser_stan/Desktop/example.txt',
level: 'read',
},
},
],
},
{
sequence: [
{
title: 'Stan grants Kyle\'s file to Eric',
call: 'grant',
as: 'testuser_stan',
with: {
to: 'testuser_eric',
permission: 'fs:/testuser_kyle/Desktop/example.txt:read',
},
},
{
title: 'Eric tries to access it',
call: 'assert-no-access',
as: 'testuser_eric',
with: {
path: '/testuser_kyle/Desktop/example.txt',
},
},
],
},
];
================================================
FILE: mods/mods_available/kdmod/gui/main.js
================================================
/*
* 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 .
*/
const request_examples = [
{
name: 'entity storage app read',
fetch: async (args) => {
return await fetch(`${window.api_origin}/drivers/call`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${puter.authToken}`,
},
body: JSON.stringify({
interface: 'puter-apps',
method: 'read',
args,
}),
method: 'POST',
});
},
out: async (resp) => {
const data = await resp.json();
if ( ! data.success ) return data;
return data.result;
},
exec: async function exec (...a) {
const resp = await this.fetch(...a);
return await this.out(resp);
},
},
{
name: 'entity storage app select all',
fetch: async () => {
return await fetch(`${window.api_origin}/drivers/call`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${puter.authToken}`,
},
body: JSON.stringify({
interface: 'puter-apps',
method: 'select',
args: { predicate: [] },
}),
method: 'POST',
});
},
out: async (resp) => {
const data = await resp.json();
if ( ! data.success ) return data;
return data.result;
},
exec: async function exec (...a) {
const resp = await this.fetch(...a);
return await this.out(resp);
},
},
{
name: 'grant permission from a user to a user',
fetch: async (user, perm) => {
return await fetch(`${window.api_origin}/auth/grant-user-user`, {
'headers': {
'Content-Type': 'application/json',
'Authorization': `Bearer ${puter.authToken}`,
},
'body': JSON.stringify({
target_username: user,
permission: perm,
}),
'method': 'POST',
});
},
out: async (resp) => {
const data = await resp.json();
return data;
},
exec: async function exec (...a) {
const resp = await this.fetch(...a);
return await this.out(resp);
},
},
{
name: 'write file',
fetch: async (path, str) => {
const endpoint = `${window.api_origin}/write`;
const token = puter.authToken;
const blob = new Blob([str], { type: 'text/plain' });
const formData = new FormData();
formData.append('create_missing_ancestors', true);
formData.append('path', path);
formData.append('size', 8);
formData.append('overwrite', true);
formData.append('file', blob, 'something.txt');
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
});
return await response.json();
},
},
];
globalThis.reqex = request_examples;
globalThis.service_script(api => {
api.on_ready(() => {
});
});
================================================
FILE: mods/mods_available/kdmod/module.js
================================================
/*
* 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 .
*/
extension.on('install', ({ services }) => {
const { CustomPuterService } = require('./CustomPuterService.js');
services.registerService('__custom-puter', CustomPuterService);
const { ShareTestService } = require('./ShareTestService.js');
services.registerService('__share-test', ShareTestService);
});
================================================
FILE: mods/mods_available/kdmod/package.json
================================================
{
"name": "custom-puter-mod",
"version": "1.0.0",
"description": "",
"main": "module.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "AGPL-3.0-only"
}
================================================
FILE: mods/mods_available/test-actions/main.js
================================================
/*
* Test-actions extension: declarative actions page for testing user suspension
* and other admin actions. All changes in this single file.
*/
const { db } = extension.import('data');
const { invalidate_cached_user } = use('core.util.helpers');
// Declarative actions: id, label, and inputs drive the generated GUI.
const ACTIONS = [
{
id: 'suspend-user',
label: 'Suspend user',
inputs: [
{ name: 'username', label: 'Username', type: 'text' },
],
},
// Add more actions here; each needs a handler in INVOKE_HANDLERS.
];
// Handlers for each action id. Receives (req, res, body).
const INVOKE_HANDLERS = {
'suspend-user': async (req, res, body) => {
const username = body?.username?.trim();
if ( ! username ) {
return res.status(400).json({ ok: false, error: 'username is required' });
}
const svc_get_user = req.services.get('get-user');
const user = await svc_get_user.get_user({ username });
if ( ! user ) {
return res.status(404).json({ ok: false, error: 'User not found' });
}
await db.write('UPDATE `user` SET suspended = 1 WHERE id = ? LIMIT 1', [user.id]);
invalidate_cached_user(user);
// Cache invalidation would require backend helpers (ESM); skipped here.
return res.json({ ok: true, message: `User "${username}" suspended.` });
},
};
const PAGE_HTML = (actionsJson) => `
Test actions
Test actions
`;
extension.get('/test-actions', (req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(PAGE_HTML(JSON.stringify(ACTIONS)));
});
extension.post('/test-actions/invoke/:actionId', async (req, res) => {
const actionId = req.params.actionId;
const handler = INVOKE_HANDLERS[actionId];
if ( ! handler ) {
return res.status(404).json({ ok: false, error: 'Unknown action' });
}
return handler(req, res, req.body || {});
});
extension.on('ai.prompt.validate', async event => {
console.log('ai.prompt.validate');
const messages = event.parameters?.messages ?? [];
console.log(`ai prompt validate: ${messages.length} messages`);
console.log('is user suspended?', event.actor.type.user.suspended);
});
================================================
FILE: mods/mods_available/test-actions/package.json
================================================
{
"name": "@heyputer/test-actions",
"version": "1.0.0",
"description": "Actions for test purposes",
"main": "main.js",
"type": "module",
"private": true
}
================================================
FILE: mods/mods_available/testex.js
================================================
// Test extension for event listeners
extension.on('ai.prompt.complete', event => {
console.log('GOT AI.PROMPT.COMPLETE EVENT', event);
});
extension.on('ai.prompt.validate', event => {
console.log('GOT AI.PROMPT.VALIDATE EVENT', event);
});
extension.on('app.new-icon', event => {
console.log('GOT APP.NEW-ICON EVENT', event);
});
extension.on('app.rename', event => {
console.log('GOT APP.RENAME EVENT', event);
});
extension.on('apps.invalidate', event => {
console.log('GOT APPS.INVALIDATE EVENT', event);
});
extension.on('email.validate', event => {
console.log('GOT EMAIL.VALIDATE EVENT', event);
});
extension.on('fs.create.directory', event => {
console.log('GOT FS.CREATE.DIRECTORY EVENT', event);
});
extension.on('fs.create.file', event => {
console.log('GOT FS.CREATE.FILE EVENT', event);
});
extension.on('fs.create.shortcut', event => {
console.log('GOT FS.CREATE.SHORTCUT EVENT', event);
});
extension.on('fs.create.symlink', event => {
console.log('GOT FS.CREATE.SYMLINK EVENT', event);
});
extension.on('fs.move.file', event => {
console.log('GOT FS.MOVE.FILE EVENT', event);
});
extension.on('fs.pending.file', event => {
console.log('GOT FS.PENDING.FILE EVENT', event);
});
extension.on('fs.storage.progress.copy', event => {
console.log('GOT FS.STORAGE.PROGRESS.COPY EVENT', event);
});
extension.on('fs.storage.upload-progress', event => {
console.log('GOT FS.STORAGE.UPLOAD-PROGRESS EVENT', event);
});
extension.on('fs.write.file', event => {
console.log('GOT FS.WRITE.FILE EVENT', event);
});
extension.on('ip.validate', event => {
console.log('GOT IP.VALIDATE EVENT', event);
});
extension.on('outer.fs.write-hash', event => {
console.log('GOT OUTER.FS.WRITE-HASH EVENT', event);
});
extension.on('outer.gui.item.added', event => {
console.log('GOT OUTER.GUI.ITEM.ADDED EVENT', event);
});
extension.on('outer.gui.item.moved', event => {
console.log('GOT OUTER.GUI.ITEM.MOVED EVENT', event);
});
extension.on('outer.gui.item.pending', event => {
console.log('GOT OUTER.GUI.ITEM.PENDING EVENT', event);
});
extension.on('outer.gui.item.updated', event => {
console.log('GOT OUTER.GUI.ITEM.UPDATED EVENT', event);
});
extension.on('outer.gui.notif.ack', event => {
console.log('GOT OUTER.GUI.NOTIF.ACK EVENT', event);
});
extension.on('outer.gui.notif.message', event => {
console.log('GOT OUTER.GUI.NOTIF.MESSAGE EVENT', event);
});
extension.on('outer.gui.notif.persisted', event => {
console.log('GOT OUTER.GUI.NOTIF.PERSISTED EVENT', event);
});
extension.on('outer.gui.notif.unreads', event => {
console.log('GOT OUTER.GUI.NOTIF.UNREADS EVENT', event);
});
extension.on('outer.gui.submission.done', event => {
console.log('GOT OUTER.GUI.SUBMISSION.DONE EVENT', event);
});
extension.on('puter-exec.submission.done', event => {
console.log('GOT PUTER-EXEC.SUBMISSION.DONE EVENT', event);
});
extension.on('request.measured', event => {
console.log('GOT REQUEST.MEASURED EVENT', event);
});
extension.on('sns', event => {
console.log('GOT SNS EVENT', event);
});
extension.on('template-service.hello', event => {
console.log('GOT TEMPLATE-SERVICE.HELLO EVENT', event);
});
extension.on('usages.query', event => {
console.log('GOT USAGES.QUERY EVENT', event);
});
extension.on('user.email-changed', event => {
console.log('GOT USER.EMAIL-CHANGED EVENT', event);
});
extension.on('user.email-confirmed', event => {
console.log('GOT USER.EMAIL-CONFIRMED EVENT', event);
});
extension.on('user.save_account', event => {
console.log('GOT USER.SAVE_ACCOUNT EVENT', event);
});
extension.on('web.socket.connected', event => {
console.log('GOT WEB.SOCKET.CONNECTED EVENT', event);
});
extension.on('web.socket.user-connected', event => {
console.log('GOT WEB.SOCKET.USER-CONNECTED EVENT', event);
});
extension.on('wisp.get-policy', event => {
console.log('GOT WISP.GET-POLICY EVENT', event);
});
================================================
FILE: mods/mods_enabled/.gitignore
================================================
*
!.gitignore
================================================
FILE: package.json
================================================
{
"name": "puter.com",
"version": "2.5.1",
"author": "Puter Technologies Inc.",
"license": "AGPL-3.0-only",
"description": "Desktop environment in the browser!",
"homepage": "https://puter.com",
"type": "module",
"main": "exports.js",
"directories": {
"lib": "lib"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@playwright/test": "^1.56.1",
"@stylistic/eslint-plugin": "^5.3.1",
"@types/mime-types": "^3.0.1",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1",
"@vitest/coverage-v8": "^4.1.0",
"@vitest/ui": "^4.1.0",
"chalk": "^4.1.0",
"clean-css": "^5.3.2",
"dotenv": "^16.4.5",
"eslint": "^9.35.0",
"eslint-rule-composer": "^0.3.0",
"express": "^4.18.2",
"globals": "^15.15.0",
"html-entities": "^2.3.3",
"html-webpack-plugin": "^5.6.0",
"husky": "^9.1.7",
"license-check-and-add": "^4.0.5",
"mocha": "^7.2.0",
"nodemon": "^3.1.0",
"simple-git": "^3.32.3",
"ts-proto": "^2.8.0",
"typescript": "^5.4.5",
"uglify-js": "^3.17.4",
"vite-plugin-static-copy": "^3.3.0",
"vitest": "^4.1.0",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.1",
"yaml": "^2.8.1"
},
"scripts": {
"test": "npx vitest run --config=src/backend/vitest.config.ts && node src/backend/tools/test.mjs",
"test:puterjs-api": "vitest run tests/puterJsApiTests",
"test:backend": "npm run build:ts; vitest run --config=src/backend/vitest.config.ts",
"test:backend-coverage": "npm run build:ts; vitest run --config=src/backend/vitest.config.ts",
"start=gui": "nodemon --exec \"node dev-server.js\" ",
"start": "node ./tools/run-selfhosted.js",
"prestart": "npm run build:ts",
"dev": "npm run build:ts && DEVCONSOLE=1 node ./tools/run-selfhosted.js",
"build": "npx eslint --quiet -c eslint/mandatory.eslint.config.js src/backend/src extensions && npm run build:ts && cd src/gui && node ./build.js",
"check-translations": "node tools/check-translations.js",
"prepare": "husky",
"build:ts": "tsc -p tsconfig.build.json",
"gen": "./scripts/gen.sh"
},
"workspaces": [
"src/*",
"tools/*",
"experiments/js-parse-and-output"
],
"nodemonConfig": {
"ext": "js, json, mjs, jsx, svg, css",
"ignore": [
"./dist/",
"./node_modules/"
]
},
"dependencies": {
"@ai-sdk/openai": "^3.0.25",
"@anthropic-ai/sdk": "^0.68.0",
"@aws-sdk/client-dynamodb": "^3.490.0",
"@aws-sdk/client-secrets-manager": "^3.879.0",
"@aws-sdk/client-sns": "^3.907.0",
"@aws-sdk/lib-dynamodb": "^3.490.0",
"@google/genai": "^1.19.0",
"@heyputer/putility": "^1.0.2",
"@paralleldrive/cuid2": "^2.2.2",
"@stylistic/eslint-plugin-js": "^4.4.1",
"ai": "^6.0.73",
"dedent": "^1.5.3",
"dynalite": "^4.0.0",
"express-xml-bodyparser": "^0.4.1",
"file-type": "21.3.3",
"javascript-time-ago": "^2.5.11",
"json-colorizer": "^3.0.1",
"music-metadata": "11.12.3",
"open": "^10.1.0",
"parse-domain": "^8.2.2",
"string-template": "^1.0.0",
"uuid": "^9.0.1"
},
"optionalDependencies": {
"sharp": "^0.34.4",
"sharp-bmp": "^0.1.5",
"sharp-ico": "^0.1.5"
},
"engines": {
"node": ">=24.0.0"
}
}
================================================
FILE: rust-toolchain.toml
================================================
[toolchain]
channel = "nightly"
components = [ "rustc", "rust-std" ]
targets = [ "wasm32-unknown-unknown", "i686-unknown-linux-gnu" ]
profile = "minimal"
================================================
FILE: scripts/gen.sh
================================================
#!/usr/bin/env bash
set -euo pipefail
protoc \
-I=src/backend/src/filesystem/definitions/proto \
--plugin=protoc-gen-ts_proto=$(npm root)/.bin/protoc-gen-ts_proto \
--ts_proto_out=src/backend/src/filesystem/definitions/ts \
--ts_proto_opt=esModuleInterop=true,outputServices=none,outputJsonMethods=true,useExactTypes=false,snakeToCamel=false \
src/backend/src/filesystem/definitions/proto/fsentry.proto
================================================
FILE: src/backend/.gitignore
================================================
# MAC OS hidden directory settings file
.DS_Store
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.production
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# End of https://www.toptal.com/developers/gitignore/api/node
public/.DS_Store
*.zip
*.pem
public/.DS_Store
public/.DS_Store
public/.DS_Store
./build
build
# config file
volatile/
ssl
ssl/
keys
*.code-workspace
# credentials
creds*
# test-webhook persisted key
tools/.test-webhook-config.json
# thumbnai-service
thumbnail-service
# init sql generated from ./run.sh
init.sql
================================================
FILE: src/backend/CONTRIBUTING.md
================================================
# Contributing to Puter's Backend
## File Structure
## Architecture
- [boot sequence](./doc/contributors/boot-sequence.md)
- [modules and services](./doc/contributors/modules.md)
## Features
- [protected apps](./doc/features/protected-apps.md)
- [service scripts](./doc/features/service-scripts.md)
## Lists of Things
- [list of permissions](./doc/lists-of-things/list-of-permissions.md)
## Code-First Approach
If you prefer to understand a system by looking at the
first files which are invoked and starting from there,
here's a handy list!
- [Kernel](./src/Kernel.js), despite its intimidating name, is a
relatively simple (< 200 LOC) class which loads the modules
(modules register services), and then starts all the services.
- [RuntimeEnvironment](./src/boot/RuntimeEnvironment.js)
sets the configuration and runtime directories. It's invoked by Kernel.
- The default setup for running a self-hosted Puter loads these modules:
- [CoreModule](./src/CoreModule.js)
- [DatabaseModule](./src/DatabaseModule.js)
- [LocalDiskStorageModule](./src/LocalDiskStorageModule.js)
- HTTP endpoints are registered with
[WebServerService](./src/services/WebServerService.js)
by these services:
- [ServeGUIService](./src/services/ServeGUIService.js)
- [PuterAPIService](./src/services/PuterAPIService.js)
- [FilesystemAPIService](./src/services/FilesystemAPIService.js)
## Development Philosophies
### The copy-paste rule
If you're copying and pasting code, you need to ask this question:
- am I copying as a reference (i.e. how this function is used),
- or am I copying an implementation of actual behavior?
If your answer is the first, you should find more than one piece of
code that's doing the same thing you want to do and see if any of them
are doing it differently. One of the ways of doing this thing is going
to be more recent and/or (yes, potentially "or") more correct.
More correct approaches are ones which reduce
[coupling](https://en.wikipedia.org/wiki/Coupling_(computer_programming)),
move from legacy implementations to more recent ones, and are actually
more convenient for you to use. Whenever ever any of these three things
are in contention it's very important to communicate this to the
appropriate maintainers and contributors.
If your answer is the second, you should find a way to
[DRY that code](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself).
### Architecture Mistakes? You will make them and it will suck.
In my experience, the harder I think about the correct way to implement
something, the bigger a mistake I'm going to make; ***unless*** a big part
of the reason I'm thinking so hard is because I want to find a solution
that reduces complexity and has the right maintenance trade-off.
There's no easy solution for this so just keep it in mind; there are some
things we might write 2 times, 3 times, even more times over before we
really get it right and *that's okay*; sometimes part of doing useful work is
doing the useless work that reveals what the useful work is.
## Underlying Constructs
- [putility's README.md](../putility/README.md)
- Whenever you see `AdvancedBase`, that's from here
- Many things in backend extend this. Anything that doesn't only doesn't
because it was written before `AdvancedBase` existed.
- Allows adding "traits" to classes
- Have you ever wanted to wrap every method of a class with
common behavior? This can do that!
================================================
FILE: src/backend/README.md
================================================
# Puter Backend
_Part of a High-Level Distributed Operating System_
Whether or not you call Puter an operating system
(we call it a "high-level distributed operating system"),
**operating systems for devices**
are a useful reference point to describe the architecture of Puter.
If Puter's "hardware" is services, and Puter's "userspace" is the
client side of the API, then Puter's "kernel" is the backend.
Puter's backend is composed of:
- The **Kernel** class, which is responsible for initialization
- A number of **Modules** which are registered in **Kernel** for a customized
Puter instance.
- Many **Services** which are contained inside modules.
## Documentation
- [Backend File Structure](./doc/contributors/structure.md)
- [Boot Sequence](./doc/contributors/boot-sequence.md)
- [Kernel](./doc/Kernel.md)
- [Modules](./doc/contributors/modules.md)
## Can I use Puter's Backend Alone?
Puter's backend is not dependent on Puter's frontned. In fact, you could
prevent Puter's GUI from ever showing up by disabling PuterHomepageModule.
Similarly, you can run Puter's backend with no modules loaded for a completely
blank slate, or only include CoreModule and WebModule to quickly build your
own backend that's compatible with any of Puter's services.
## What can it do?
Puter's Kernel only initializes modules, nothing more. The modules bring a lot
of capabilities to the table, however. Within this directory you'll find modules that:
- coerce all the well-known AI services to a common interface
- manage authentication with Wisp servers (this brings TCP to the browser!)
- manage apps on Puter
- allow a user to host websites from Puter
- provide persistent key-value storage to Puter's desktop and apps
- provide a fast filesystem implementation
- communicate with other instances of Puter's backend,
secured with elliptic curve cryptography
- provide more services like converting files and compiling low-level code.

================================================
FILE: src/backend/doc/A-and-A/auth.md
================================================
# Authentication Documentation
## Concepts
### Actor
An "Actor" is an entity that can be authenticated. The following types of
actors are currently supported by Puter:
- **UserActorType** - represents a user and is identified by a user's UUID
- **AppUnderUserActorType** - represents an app running in an iframe from a
`puter.site` domain or another origin and is identified by a user's UUID
and an app's UUID together.
- **AccessTokenActorType** - not widely currently, but Puter supports
a concept called "access tokens". Any user can create an access token and
then grant any permissions they want to that access token. The access
token will have those permissions granted provided that the user who
created the access token does as well (via permission cascade)
- **SiteActorType** - represents a `puter.site` website accessing Puter's API.
- **SystemActorType** - internal representation of the actor during a privileged
backend operation. This actor cannot be authenticated in a request.
This actor does not represent the `system` user.
### Token
- **Legacy** - legacy tokens result in an error response
- **Session** - this token is a JWT with a claim for the UUID of an entry in
server memory or the database that we call a "session". This entry associates
the token to a user and some metadata for security auditing purposes.
Revoking the session entry disables the token.
This type of token resolves to an actor with **UserActorType**.
- **AppUnderUser** - this token is a JWT with a claim for an app UUID and a
claim for a session UUID.
Revoking the session entry disables the token.
This type of token resolves to an actor with **AppUnderUserActorType**.
- **AccessToken** - this token is a JWT with three claims:
- A session UUID
- An optional App UUID
- A UUID representing the access token for permission associations
The session or session+app creates a **UserActorType** or
**AppUnderUserActorType** actor respectively. This actor is called
the "authorizor". This actor is aggregated by an **AccessTokenActorType**
actor which becomes the effective actor for a request.
- **ActorSite** - this token is a JWT with a claim for a site UID.
The site UID is associated with an origin, generally a `puter.site`
subdomain.
## Components
### Auth Middleware
There have so far been three iterations of the authentication middleware:
- `src/backend/src/middleware/auth.js`
- `src/backend/src/middleware/auth2.js`
- `src/backend/src/middleware/configurable_auth.js`
The newest implementation is `configurable_auth` and eventually the other
two will be removed. There is no legacy behavior involved:
- `auth` was rewritten to use `auth2`
- `auth2` was rewritten to use `configurable_auth`
The `configurable_auth` middleware accepts a parameter that can be specified
if an endpoint is optionally authenticated. In this case, the request's
`actor` will be `undefined` if there was no information for authentication.
================================================
FILE: src/backend/doc/A-and-A/permission.md
================================================
# Permission Documentation
## Concepts
### Permission
A permission is a string composed of colon-delimited components which identifies
a resource or functionality to which access can be controlled.
For example, `fs:e8ac2973-287b-4121-a75d-7e0619eb8e87:read` is a permission which
represents reading the file or directory with UUID `e8ac2973-287b-4121-a75d-7e0619eb8e87`.
### Group
A group has an owner and several member users. An owner decides what users are in the
group and what users are not. Any user can grant permissions to the group.
### Granting & Revoking
Granting is the act of creating a permission association to a user or group from
the current user. A permission association also holds an object called `extra`
which holds additional claims associated with the permission association.
These are arbitrary and can be used in any way by the subsystem or extension that
is checking the permission. `extra` is usually just an empty object.
Revoking is the act of removing a permission association.
### Permission Options
Permission options are an association between a permission and an actor that can not
be revoked by another actor. For example, the user `ed` always has access to files
under `/ed`. The user `system` always has all permissions granted. These can also be
considered "terminals" because they will always be at
the end of a pathway through granted permissions between users.
This are also called "implied" permissions because they are implied by the system.
### Permission Pathways
A permission pathway is the path between users or groups that leads to a permission.
For example, `ed` can grant the permission `a:b` to `fred`, then `fred` can grant
that permission to the group `cool_group`, and then `alice` may be in the group
`cool_group`. Assuming `ed` holds the implied permission `a:b`, a permission path
exists between `alice` and `ed` via `cool_group` and `fred`:
```
alice <--<> cool_group <-- fred <-- ed (a:b)
```
If any link in this chain breaks the permission is effectively revoked from `alice`
unless there is another pathway leading to a valid permission option for `a:b`.
### Reading - AKA Permission Scan Result
A permission reading is a JSON-serializable object which contains all the pathways
a specified actor has to permissions options matching the specified permission strings.
The following is an example reading for the user `ed3` on the permission
`fs:24729b88-a4c5-4990-ad4e-272b87895732:read`. This file is owned by the
user `admin` who shared it with `ed3`.
```
[
{
"$": "explode",
"from": "fs:24729b88-a4c5-4990-ad4e-272b87895732:read",
"to": [
"fs:24729b88-a4c5-4990-ad4e-272b87895732:read",
"fs:24729b88-a4c5-4990-ad4e-272b87895732:write",
"fs:24729b88-a4c5-4990-ad4e-272b87895732",
"fs"
]
},
{
"$": "path",
"via": "user",
"has_terminal": true,
"permission": "fs:24729b88-a4c5-4990-ad4e-272b87895732:read",
"data": {},
"holder_username": "ed3",
"issuer_username": "admin",
"reading": [
{
"$": "explode",
"from": "fs:24729b88-a4c5-4990-ad4e-272b87895732:read",
"to": [
"fs:24729b88-a4c5-4990-ad4e-272b87895732:read",
"fs:24729b88-a4c5-4990-ad4e-272b87895732:write",
"fs:24729b88-a4c5-4990-ad4e-272b87895732",
"fs"
]
},
{
"$": "option",
"permission": "fs:24729b88-a4c5-4990-ad4e-272b87895732:read",
"source": "implied",
"by": "is-owner",
"data": {}
},
{
"$": "option",
"permission": "fs:24729b88-a4c5-4990-ad4e-272b87895732:write",
"source": "implied",
"by": "is-owner",
"data": {}
},
{
"$": "option",
"permission": "fs:24729b88-a4c5-4990-ad4e-272b87895732",
"source": "implied",
"by": "is-owner",
"data": {}
},
{
"$": "time",
"value": 19
}
]
},
{
"$": "time",
"value": 20
}
]
```
Each object in the reading has a property named `$` which is the type for the object.
The most fundamental types for permission readings are `path` and `option`. A path
always contains another reading, which contains more paths or options. An option
specifies the permission string, the name of the rule that granted the permission,
and a data object which may hold additional claims.
Readings begin with an `explode` if there are multiple strings that may grant the
permission.
Readings end with a `time` that repots how long the reading took to help manage
the potential performance impact of complex permission graphs.
## Permission Service
### check(actor, permissions)
Returns true if the current actor has a path to any permission options matching
any of the permission strings specified by `permissions`. This is done by invoking
`scan()` and returning `true` if there are more than 0 permission options.
### scan(actor, permissions)
Returns a "reading". A permission reading is a JSON-serializable structure.
Readings are described above.
## Permission Scan Sequence
The `scan()` method of **PermissionService** invokes the permission scan sequence.
The permission scan sequence is a [Sequence](https://github.com/HeyPuter/puter/blob/0e0bfd6d7c92eed5080518a099c9a66a2f2dc9ec/src/backend/src/codex/Sequence.js)
that is defined in [scan-permission.js](src/backend/src/structured/sequence/scan-permission.js).
It invokes many "permission scanners" which are defined in
[permission-scanners.js](src/backend/src/unstructured/permission-scanners.js)
The Permission Scan Sequence is as follows:
- `grant_if_system` - if system user, push an option to the reading and stop
- `rewrite_permission` - process the permission through any permission string
rewriters that were registered with PermissionService by other services.
For example, since path-based file permissions aren't currently supported
the FilesystemService regsiters a rewriter that converts any `fs:/`
permission into a corresponding UUID permission.
- `explode_permission` - break the permission into multiple permissions
than are sufficient to grant the permission being scanned. For example if
there are multiple components, like `a.b.c`, having either permission `a.b` or
`a` granted implis having `a.b.c` granted. Other services can also register
"permission exploders" which handle non-hierarchical cases such as
`fs:AAAA:write` implying `fs:AAAA:read`.
- `run_scanners` - run the permission scanners.
Each permission scanner has a name, documentation text, and a scan function.
The scan function has access to the scan sequence's context and can push
objects onto the permission reading.
For information on individual scanners, refer to permission-scanners.js.
================================================
FILE: src/backend/doc/Kernel.md
================================================
# Puter Kernel Documentation
## Overview
The **Puter Kernel** is the core runtime component of the Puter system. It provides the foundational infrastructure for:
- Initializing the runtime environment
- Managing internal and external modules (extensions)
- Setting up and booting core services
- Configuring logging and debugging utilities
- Integrating with third-party modules and performing dependency installs at runtime
This kernel is responsible for orchestrating the startup sequence and ensuring that all necessary services, modules, and environmental configurations are properly loaded before the application enters its operational state.
---
## Features
1. **Modular Architecture**:
The Kernel supports both internal and external modules:
- **Internal Modules**: Provided to Kernel by an initializing script, such
as `tools/run-selfhosted.js`, via the `add_module()` method.
- **External Modules**: Discovered in configured module directories and installed
dynamically. This includes resolving and executing `package.json` entries and
running `npm install` as needed.
2. **Service Container & Registry**:
The Kernel initializes a service container that manages a wide range of services. Services can:
- Register modules
- Initialize dependencies
- Emit lifecycle events (`boot.consolidation`, `boot.activation`, `boot.ready`) to
orchestrate a stable and consistent environment.
3. **Runtime Environment Setup**:
The Kernel sets up a `RuntimeEnvironment` to determine configuration paths and environment parameters. It also provides global helpers like `kv` for key-value storage and `cl` for simplified console logging.
4. **Logging and Debugging**:
Uses a temporary `BootLogger` for the initialization phase until LogService is
initialized, at which point it will replace the boot logger. Debugging features
(`ll`, `xtra_log`) are enabled in development environments for convenience.
## Initialization & Boot Process
1. **Constructor**:
When a Kernel instance is created, it sets up basic parameters, initializes an empty
module list, and prepares `useapi()` integration.
2. **Booting**:
The `boot()` method:
- Parses CLI arguments using `yargs`.
- Calls `_runtime_init()` to set up the `RuntimeEnvironment` and boot logger.
- Initializes global debugging/logging utilities.
- Sets up the service container (usually called `services`c instance of **Container**).
- Invokes module installation and service bootstrapping processes.
3. **Module Installation**:
Internal modules are registered and installed first.
External modules are discovered, packaged, installed, and their code is executed.
External modules are given a special context with access to `useapi()`, a dynamic
import mechanism for Puter modules and extensions.
4. **Service Bootstrapping**:
After modules and extensions are installed, services are initialized and activated.
For more information about how this works, see [boot-sequence.md](./contributors/boot-sequence.md).
================================================
FILE: src/backend/doc/README.md
================================================
## Backend - Contributor Documentation
### Where to Start
Start with [Backend File Structure](./contributors/structure.md).
There also also some videos. In one of the videos Eric does a
Steve Ballmer impression so it's definitely worth it.
- [Services and Modules in Puter](https://www.youtube.com/watch?v=TOeS67QXMVU)
- [Puter's Boot Sequence](https://www.youtube.com/watch?v=a8bOLNnW1Uo)
- [Building a Driver on Puter](https://www.youtube.com/watch?v=8znQmrKgNxA)
### Index
- [Backend File Structure](./contributors/structure.md)
- [Boot Sequence](./contributors/boot-sequence.md)
- [Kernel](./Kernel.md)
- [Modules](./contributors/modules.md)
- [Configuring Logs](./log_config.md)
================================================
FILE: src/backend/doc/contributors/boot-sequence.md
================================================
# Puter Backend Boot Sequence
This document describes the boot sequence of Puter's backend.
**Runtime Environment**
- Configuration directory is determined
- Runtime directory is determined
- Mod directory is determined
- Services are instantiated
**Construction**
- Data structures are created
**Initialization**
- Registries are populated
- Services prepare for next phase
**Consolidation**
- Service event bus receives first event (`boot.consolidation`)
- Services perform coordinated setup behaviors
- Services prepare for next phase
**Activation**
- Blocking listeners of `boot.consolidation` have resolved
- HTTP servers start listening
**Ready**
- Services are informed that Puter is providing service
## Boot Phases
### Construction
Services implement a method called `construct` which initializes members
of an instance. Services do not override the class constructor of
**BaseService**. This makes it possible to use the `new` operator without
invoking a service's constructor behavior during debugging.
The first phase of the boot sequence, "construction", is simply a loop to
call `construct` on all registered services.
The `_construct` override should not:
- call other services
- emit events
### Initialization
At initialization, the `init()` method is called on all services.
The `_init` override can be used to:
- register information with other services, when services don't
need to register this information in a specific sequence.
An example of this is registering commands with CommandService.
- perform setup that is required before the consolidation phase starts.
### Consolidation
Consolidation is a phase where services should emit events that
are related to bringing up the system. For example, WebServerService
('web-server') emits an event telling services to install middlewares,
and later emits an event telling services to install routes.
Consolidation starts when Kernel emits `boot.consolidation` to the
services event bus, which happens after `init()` resolves for all
services.
### Activation
Activation is a phase where services begin listening on external
interfaces. For example, this is when the web server starts listening.
Activation starts when Kernel emits `boot.activation`.
### Ready
Ready is a phase where services are informed that everything is up.
Ready starts when Kernel emits `boot.ready`.
## Events and Asynchronous Execution
The services event bus is implemented so you can `await` a call to `.emit()`.
Event listeners can choose to have blocking behavior by returning a promise.
During emission of a particular event, listeners of this event will not
block each other, but all listeners must resolve before the call to
`.emit()` is resolved. (i.e. `emit` uses `Promise.all`)
## Legacy Services
Some services were implemented before the `BaseService` class - which
implements the `init` method - was created. These services are called
"legacy services" and they are instantiated _after_ initialization but
_before_ consolidation.
================================================
FILE: src/backend/doc/contributors/coding-style.md
================================================
# Backend Style
## File Structure
### Copyright Notice
All files should begin with the standard copyright notice:
```javascript
/*
* Copyright (C) 2025-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 .
*/
```
### Imports
```javascript
const express = require('express');
const passport = require('passport');
const { get_user } = require("../../helpers");
const BaseService = require("../../services/BaseService");
const config = require("../../config");
const path = require('path');
const fs = require('fs');
```
Import order is generally:
1. Third party dependencies. Having these occur first makes it easy to quickly
determine what this source file is likely to be responsible for.
2. Files within the module.
3. Standard library, "builtins"
## Code Formatting
### Indentation and Spacing
```javascript
const fn = async () => {
const a = 5; // Spaces between operators
// Note: "=" in for loop initializer does not require space around
// Note: operators in condition part have space around
for ( let i=0; i < 10; i++ ) {
console.log('hello');
}
// Control structures have space inside parenthesis
for ( const thing of stuff ) {
// NOOP
}
// Function calls do not have space inside parenthesis
await something(1, 2);
}
```
- Use 4 spaces for indentation.
- Use spaces around operators (`=`, `+`, etc.); not required in
for loop initializer.
- Use a space after keywords like `if`, `for`, `while`, etc.
```javascript
return [1,2,3]; // Sure
return[1,2,4]; // Definitely not
```
- Use spaces between parenthesis in control structures unless
parenthesis are empty.
```javascript
if ( a === b ) {
return null;
}
```
- No trailing whitespace at the end of lines
- Use a space after commas in arrays and objects
- Empty blocks should have the comment `// NOOP` within braces
### Line Length
- Try to keep lines under 100 characters for better readability
- Try to keep them under 80, but this is not always practical
- For long function calls or objects, break them into multiple lines
### Trailing Commas
```javascript
// This is great
{
"apple",
"banana",
"cactus", // <-- Good!
}
// This is also fine
[
1, 2, 3,
4, 5, 6,
7, 8, 9,
]
[
something(),
another_thing(),
the_last_thing() // <-- Nope, please add trailing comma!
]
```
We use trailing commas where applicable because it's easier to re-order
lines, especially when using vim motions.
### Braces and Blocks
- Single statement blocks must either be on the same line as
the corresponding control structure, or surrounding by braces:
```javascript
if ( a === b ) return null; // Sure
if ( a === b )
return null; // Please no 🤮
if ( a === b ) {
return null; // Nice
}
```
- Opening braces go on the same line as the statement
- Put a space before the opening brace
## Naming Conventions
### Variables
- Variables are generally in camelCase
- Variables might have a prefix_beforeThem
```javascript
const svc_systemData = this.services.get('system-data');
const svc_su = this.services.get('su');
effective_policy = await svc_su.sudo(async () => {
return await svc_systemData.interpret(effective_policy.data);
});
```
In the example above we see the `svc_` prefix is used to indicate a
reference to a backend service. The name of the service is `system-data`
which is not a valid identifier, so we use `svc_systemData` for our
variable name.
### Classes
- Use PascalCase for class names
- Use snake_case for class methods
- Instance variables are often `snake_case` because it's easier to
read. `camelCase` is acceptable too.
- Instance variables only used internally should have a
`trailing_underscore_` even if in `camelCase_`. We avoid using
`#privateProperties` because it unnecessarily inhibits debugging
and patching.
### File Names
- Use PascalCase for class files (e.g., `UserService.js`)
- Use kebab-case for non-class files (e.g., `auth-helper.js`)
## Documentation
### JSDoc Comments
- Backend services (classes extending `BaseService`) should have JSDoc comments
- Public methods of backend services should have JSDoc comments
- Include parameter descriptions, return values, and examples where appropriate
```javascript
/**
* @class UserService
* @description Service for managing user operations
*/
/**
* Get a user by their ID
* @param {string} id - The user ID
* @returns {Promise} The user object
* @throws {Error} If user not found
*/
async function getUserById(id) {
// ...
}
```
### Inline Comments
- Use inline comments to explain complex logic
- Prefix comments with tags like `track:` to indicate specific purposes
```javascript
// track: slice a prefix
const uid = uid_part.slice('uid#'.length);
```
================================================
FILE: src/backend/doc/contributors/modules.md
================================================
# Puter Kernel Moduels and Services
## Modules
A Puter kernel module is simply a collection of services that run when
the module is installed. You can find an example of this in the
`run-selfhosted.js` script at the root of the Puter monorepo.
Here is the relevant excerpt in `run-selfhosted.js` at the time of
writing this documentation:
```javascript
const {
Kernel,
CoreModule,
DatabaseModule,
LocalDiskStorageModule,
SelfHostedModule
} = (await import('@heyputer/backend')).default;
const k = new Kernel();
k.add_module(new CoreModule());
k.add_module(new DatabaseModule());
k.add_module(new LocalDiskStorageModule());
k.add_module(new SelfHostedModule());
k.boot();
```
A few modules are added to Puter before booting. If you want to install
your own modules into Puter you can edit this file for self-hosted runs
or create your own script that boots Puter. This makes it possible to
have deployments of Puter with custom functionality.
To function properly, Puter needs **CoreModule**, a database module,
and a storage module.
A module extends
[AdvancedBase](../../../putility/README.md)
and implements
an `install` method. The install method has one parameter, a
[Context](../../src/util/context.js)
object containing all the values kernel modules have access to. This
includes the `services`
[Container](../../src/services/Container.js`).
A module adds services to Puter.eA typical module may look something
like this:
```javascript
class MyPuterModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const MyService = require('./path/to/MyService.js');
services.registerService('my-service', MyService, {
some_options: 'for-my-service',
});
}
}
```
## Services
Services extend
[BaseService](../../src/services/BaseService.js)
and provide additional functionality for Puter. They can add HTTP
endpoints and register objects with other services.
When implementing a service it is important to understand
Puter's [boot sequence](./boot-sequence.md)
A typical service may look like this:
```javascript
class MyService extends BaseService {
static MODULES = {
// Use node's `require` function to populate this object;
// this makes these available to `this.require` and offers
// dependency-injection for unit testing.
['some-module']: require('some-module')
}
// Do not override the constructor of BaseService - use this instead!
async _construct () {
this.my_list = [];
}
// This method is called after _construct has been called on all
// other services.
async _init () {
const services = this.services;
// We can get the instances of other services here
const svc_otherService = services.get('other-service');
}
// The service container can listen on the "service event bus"
async ['__on_boot.consolidation'] () {}
async ['__on_boot.activation'] () {}
async ['__on_start.webserver'] () {}
async ['__on_install.routes'] () {}
}
```
================================================
FILE: src/backend/doc/contributors/structure.md
================================================
# Puter Backend - Directory Structure
## MFU - Most Frequently Used
These locations under `/src/backend/src` are the most important
to know about. Whether you're contributing a feature or fixing a bug,
you might only need to look at code in these locations.
### `modules` directory
The `modules` directory contains Puter backend kernel modules only.
Everything in here has a `Module.js` file and one or more
`Service.js` files.
> **Note:** A "backend kernel module" is simply a class understood by
[`src/backend/src/Kernel.js`](../../src/Kernel.js)
that registers a number of "Service" classes.
You can look at [Puter's init file](../../../../tools/run-selfhosted.js)
to see how modules are added to Puter.
The `README.md` file inside any module directory is generated with
the `module-docgen` script in the Puter repo's `/tools` directory.
The actual documentation for the module exists in jsdoc comments
in the source files.
Each module might contain these directories:
- `doc/` - additional module documentation, like sample requests
- `lib/` - utility code that isn't a Module or Service class.
This utility code may be exposed by a service in the module
to Puter's runtime import mechanism for extension support.
### `services` directory
This directory existed before the `modules` directory. Most of
the services here go on a module called **CoreModule**
(CoreModule.js is directly in `/src/backend/src`), but this
directory can be thought of as "services that are not yet
organized in a distinct module".
### `routers` directory
While routes are typically registered by Services, the implementation
of a route might be placed under `src/backend/src/routers` to keep the
service's code tidy or for legacy reasons.
These are some services that reference files under `src/backend/src/routers`:
- [PermissionAPIService](../../src/services/PermissionAPIService.js) -
This service registers routes that allow a user to configure permissions they
grant to apps and groups. This is a relatively recent case of using files under the
`routers` directory to clean up the service.
- [UserProtectedEndpointsService](../../src/services/web/UserProtectedEndpointsService.js) -
This service follows a slightly different approach where files under
`routers/user-protected` contain an "endpoint specification" instead of an express
handler function. This might be good inspiration for future routes.
- [PuterAPIService](../../src/services/PuterAPIService.js) -
This service is a catch-all for routes that existed before separation of concerns
into backend kernel modules.
### `filesystem` directory
The filesystem is likely the most complex portion of Puter's source code. This code
is in its own directory as a matter of circumstance more than intention. Ideally the
filesystem's concerns will be split across a few modules as we prepare to add
support for mounting different file systems and improved cache behavior.
For example, Puter's native filesystem implementation should be mostly moved to
`src/backend/src/modules/puterfs` as we continue this development.
Since this directory is in flux, don't trust this documentation completely.
If you're contributing to filesystem,
[tag @KernelDeimos on the community Discord](https://discord.gg/PQcx7Teh8u)
if you have questions.
These are the key locations in the `filesystem` directory:
- `FSNodeContext.js` - When you have a reference to a file or directory in backend code,
it is an instance of the FSNodeContext class.
- `ll_operations` - Runnables that implement the behavior of a filesystem operation.
These used to include the behavior of Puter's filesystem, but they now delegate
the actual behavior to the implementation in the `.provider` member of a
FSNodeContext (filesystem node / a file or directory) so that we can eventually
support "mountpoints" (multiple filesystem implementations).
- `hl_operations` - Runnables that implement the behavior of higher-level versions
of filesystem operations. For example, the high-level mkdir operation might create
multiple directories in chain; the high-level write might change the name of the
file to avoid conflicts if you specify the `dedupe_name` flag.
================================================
FILE: src/backend/doc/dev_socket.md
================================================
## Backend - dev socket
The "dev socket" allows you to interact with Puter's backend by running commands.
It's a UNIX socket that lets you run commands registered with
[CommandService](../../src/services/CommandService.js) (e.g. `help`, `logs:indent`, `params:get`, etc.).
### Enabling the dev socket
The dev socket is provided by the **dev-console extension** and is **opt-in**. To enable it:
1. Set the environment variable `DEVCONSOLE=1` when starting Puter (e.g. `npm run dev` already does this).
2. The extension lives in `extensions/dev-console/` and registers a `dev-socket` service when `DEVCONSOLE=1`.
### Socket location
The socket is created in a directory chosen as follows (in order):
- `PUTER_DEV_SOCKET_DIR` if set
- `./volatile/runtime` if it exists (typical local dev)
- otherwise the process current working directory
The socket file is named `dev.sock`.
### Connecting
When in that directory, connect with your tool of choice. For example, using `nc` and `rlwrap` for readline history:
```
rlwrap nc -U ./dev.sock
```
If it is successful you will see a message with instructions. Enter a command (e.g. `help`) and press Enter.
================================================
FILE: src/backend/doc/extensions/README.md
================================================
# Puter Backend Extensions
## What Are Extensions
Extensions can extend the functionality of Puter's backend by handling specific
events or importing/exporting runtime libraries.
## Creating an Extension
The easiest way to create an extension is to place a new file or directory under
the `extensions/` directory immediately under the root directory of the Puter
repository. If your extension is a single `.js` file called `my-extension.js` it
will be implicitly converted into a CJS module with the following structure:
```
extensions/
|
|- my-extension/
|
|- package.json
|- main.js
```
The location of the extensions directory can be changed in
[the config file](../../../../doc/self-hosters/config.md)
by setting `mod_directories` to an array of valid locations.
The `mod_directories` parameter has the following default value:
```json
["{repo}/mods/mods_enabled", "{repo}/extensions"]
```
### Events
The primary mechanism of communication between extensions and Puter,
and between different extensions, is through events. The `extension`
pseudo-global provides `.on(fn)` to add event listemers and
`.emit('name', { arbitrary: 'data' })` to emit events.
To try working with events, you could make a simple extension that
emits an event after adding a listener for its own event:
```javascript
// Listen to a test event called 'test-event'
extension.on('test-event', event => {
console.log(`We got the test event from ${sender}`);
});
// Listen to init; a good time to emit events
extension.on('init', event => {
extension.emit('test-event', { sender: 'Quinn' });
});
```
### Puter Extension Imports
Your extensions may need to invoke specific actions in Puter's backend
in response to an event. Puter provides libraries at runtime which you
can access via `extension.imports`:
```javascript
const { kv } = extension.imports('data');
kv.set('some-key', 'some value');
```
#### The `data` import
The data import makes it possible to access Puter's database, persistent
key-value store, and in-memory cache.
- [Read more about the 'data' import](./builtins/data.md)
### Adding Features to Puter
- [Implementing Drivers](./pages/drivers.md)
### Bundled extensions
- **dev-console** – When `DEVCONSOLE=1` is set (e.g. `npm run dev`), the dev-console extension registers a UNIX socket (`dev.sock`) so you can run backend commands (see [CommandService](../../src/services/CommandService.js)) from a terminal. See [Backend – dev socket](../dev_socket.md).
## Extensions - Planned Features
Extensions are under refactor currently. This is the checklist:
- [x] Add RuntimeModule construct for imports and exports
- [x] Add support to implement drivers in extensions
- [ ] Add the ability to target specific extensions when
emitting events
- [ ] Add event name aliasing and configurable import mapping
- [ ] Extract extension loading from the core
- [ ] List exports in console
================================================
FILE: src/backend/doc/extensions/builtins/data.md
================================================
## Extensions - the `data` extension
The `data` extension can be imported in custom extensions for access
to the database and key-value store.
You can import these from `'data'`:
- `db` - Puter's main SQL database
- `kv` - A persistent key-value store
- `cache` - In-memory [kv.js](https://github.com/HeyPuter/kv.js/) store
```javascript
const { db, kv, cache } = extension.import('data');
```
### Database (`db`)
Don't forget to import it first!
```javascript
const { db } = extension.import('data');
```
#### `db.read`
Usage:
```javascript
const rows = await db.read('SELECT * FROM apps WHERE `name` = ?', [
'editor'
]);
```
#### `db.write`
Usage:
```javascript
const {
insertId, // internal ID of new row (if this is an INSERT)
anyRowsAffected, // true if 1 or more rows were affected
} = await db.write(
// A query like INSERT, UPDATE, DELETE, etc...
'INSERT INTO example_table (a, b, c) VALUES (?, ?, ?)',
// Parameters (all user input should go here)
[
"Value for column a",
"Value for column b",
"Value for column c",
]
);
```
### Persistent KV Store (`kv`)
Don't forget to import it first!
```javascript
const { kv } = extension.import('data');
```
#### `kv.get({ key })`
```javascript
// Short-Form (like kv.js)
const someValue = kv.get('some-key');
// Long-Form (the `puter-kvstore` driver interface)
const someValue = kv.get({ key: 'some-key' });
```
#### `kv.set({ key, value })`
```javascript
await kv.set('some-key', 'some value');
// or...
await kv.set({
key: 'some-key',
value: 'some value',
});
```
#### `kv.expire({ key, ttl })`
This key will persist for 20 minutes, even if the server restarts.
```javascript
kv.expire({
key: 'some-key',
ttl: 1000 * 60 * 20, // 20 minutes
});
```
### `kv.expireAt({ key, timestamp })`
The following example expires a key 1 second before
["the apocalypse"](https://en.wikipedia.org/wiki/Year_2038_problem).
(don't worry, KV won't break in 2038)
```javascript
kv.expireAt(
key: 'some-key',
// Expires Jan 19 2038 3:14:07 GMT
timestamp: 2147483647,
);
```
### In-Memory Cache (`cache`)
Don't forget to import it first!
```javascript
const { cache } = extension.import('data');
```
The in-memory cache is provided by [kv.js](https://github.com/HeyPuter/kv.js).
Below is a simple example.
For comprehensive documentation, see the [kv.js repository's readme](https://github.com/HeyPuter/kv.js/blob/main/README.md).
```javascript
const { cache } = extension.require('data');
cache.set('some-key', 'some value');
const value = cache.get('some-key'); // some value
// This value only exists for 5 minutes
cache.set('temporary', 'abcdefg', { EX: 5 * 60 });
cache.incr('qwerty'); // cache.get('qwerty') is now: 1
cache.incr('qwerty'); // cache.get('qwerty') is now: 2
```
================================================
FILE: src/backend/doc/extensions/pages/core-devs.md
================================================
## Extensions - Technical Context for Core Devs
This document provides technical context for extensions from the perspective of
core backend modules and services, including the backend kernel.
### Lifecycle
For extensions, the concept of an "init" event handler is different from core.
This is because a developer of an extension expects `init` to occur after core
modules and services have been initialized. For this reason, extensions receive
`init` when backend services receive `boot.consolidation`.
It is still possible to handle core's `init` event in an extension. This is done
using the `preinit` event.
```
Backend Core Lifecycle
Modules -> Construction -> Initialization -> Consolidation -> Activation -> Ready
Extension Lifecycle
index.js executed -> (no event) -> 'preinit' -> 'init' -> (no event) -> 'ready'
```
Extensions have an implicit Service instance that needs to listen for events on
the **Service Event Bus** such as `install.routes` (emitted by WebServerService).
Since extensions need to affect the behavior of the service when these events
occur (for example using `extension.post()` to add a POST handler) it is necessary
for their entry files to be loaded during a module installation phase, when
services are being registered and `_construct()` has not yet been called on any
service.
Kernel.js loads all core modules/services before any extensions. This allows
core modules and services to create [runtime modules](./runtime-modules.md)
which can be imported by services.
### How Extensions are Loaded
Before extensions are loaded, all of Puter's core modules have their `.install()`
methods called. The core modules are the ones added with `kernel.add_module`,
for example in [run-selfhosted.js](../../../../../tools/run-selfhosted.js).
Then, `Kernel.install_extern_mods_` is called. This is where a `readdir` is
performed on each directory listed in the `"mod_directories"` configuration
parameter, which has a default value of `["{repo}/extensions"]` (the
placeholder `{repo}` is automatically replaced with the path to the Puter
repository).
For each item in each mod directory, except for ignored items like `.git`
directories, a mod is installed. First a directory is created in Puter's
runtime directory (`volatile/runtime` locally, `/var/puter` on a server).
If the item is a file then a `package.json` will be created for it after
`//@extension` directives are processed. If the item is a directory then
it is copied as is and `//@extension` directives are not supported
(`puter.json` is used instead). Source files for the mod are copied to
the mod directory under the runtime directory.
It is at this point the pseudo-globals are added be prepending `cost`
declarations at the top of `.js` files in the extension. This is not
a great way to do this, but there is a severe lack of options here.
See the heading below - "Extension Pseudo-Globals" - for details.
Before the entry file for the extension is `require()`'d a couple of
objects are created: an `ExtensionModule` and an `Extension`.
The `ExtensionModule` is a Puter module just like any of the Puter core
modules, so it has an `.install()` method that installs services before
Puter's kernel starts the initialization sequence. In this case it will
install the implied service that an extension creates if it registers
routes or performs any other action that's typically done inside services
in core modules.
A RuntimeModule is also created. This could be thought of as analygous
to node's own `Module` class, but instead of being for imports/exports
between npm modules it's for imports/exports between Puter extensions
loaded at runtime. (see [runtime modules](./runtime-modules.md))
### Extension Pseudo-Globals
The `extension` global is a different object per extension, which will
make it possible to develop "remapping" for imports/exports when
extension names collide among other functions that need context about
which extension is calling them. Implementing this per-extension global
was very tricky and many solutions were considered, including using the
`node:vm` builtin module to run the extension in a different instance.
Unfortunately `node:vm` support for EMCAScript Modules is lacking;
`vm.Module` has a drastically different API from `vm.Script`, requires
an experimental feature flag to be passed to node, and does not provide
any alternative to `createRequire` to make a valid linker for the
dependencies of a package being run in `node:vm`.
The current solution - which sucks - is as follows: prepend `const`
definitions to the top of every `.js` file in the extension's installation
directory unless it's under a directory called `node_modules` or `gui`.
This type of "pseudo-global" has a quirk when compared to real globals,
which is that they can't be shadowed at the root scope without an error
being thrown. The naive solution of wrapping the rest of the file's
contents in a scope limiter (`{ ... }`) would break ES Module support
because `import` directives must be in the top-level scope, and the naive
solution to that problem of moving imports to the top of the file after
adding the scope limiter requires invoking a javascript parser do
determine the difference between a line starting with `import` because
it's actually an import and this unholy abomination of a situation:
```
console.log(`
import { me, and, everything, breaks } from 'lackOfLexicalAnalysis';
`);
```
Exposing the same instance for `extension` to all extensions with a
real global and using AsyncLocalStorage to get the necessary information
about the calling extension on each of `extension`'s methods was another
idea. This would cause surprising behavior for extension developers when
calling methods on `extension` in callbacks that lose the async context
fail because of missing extension information.
Eventually a better compromise will be to have commonjs extensions
run using `vm.Script` and ESM extensions continue to run using this hack.
### Event Listener Sub-Context
In extensions, event handlers are registered using `extension.on`. These
handlers, when called, are supplemented with identifying information for
the extension through AsyncLocalStorage. This means any methods called
on the object passed from the event (usually just called `event`) will
be able to access the extension's name.
This is used by CommandService's `create.commands` event. For example
the following extension code will register the command `utils:say-hello`
if it is invoked form an extension named `utils`:
```javascript
extension.on('create.commands', event => {
event.createCommand('say-hello', async (args, console) => {
console.log('Hello,', ...args);
});
});
```
================================================
FILE: src/backend/doc/extensions/pages/drivers.md
================================================
## Extensions - Implementing Drivers
Puter's concept of drivers has existed long before the extension system
was refined, and to keep things moving forward it has become easier to
develop Puter drivers in extensions than anywhere else in Puter's source.
If you want to build a driver, an extension is the recommended way to do it.
### What are Puter drivers?
Puter drivers are all called through the `/drivers/call` endpoint, so they
can be thought of as being "above" the HTTP layer. When a method on a driver
throws an error you will still receive a `200` HTTP status response because
the the invocation - from the HTTP layer - was successful.
A driver response follows this structure:
```json
{
"success": true,
"service": {
"name": "implementation-name"
},
"result": "any type of value goes here",
"metadata": {}
}
```
There exists an example driver called `hello-world`. This driver implements
a method called `greet` with the optional parameter `subject` which returns
a string greeting either `World` (default) or the specified subject.
```javascript
await puter.call('hello-world', 'no-frills', 'greet', { subject: 'Dave' });
```
Let's break it down:
#### `'hello-world'`
`'hello-world'` is the name of an "interface". An interface can be thought of
a contract of what inputs are allowed and what outputs are expected. For
example the `hello-world` interface specifies that there must be a method
called `greet` and it should return a string representing a greeting.
To add another example, an interface called `weather` specify a method called
`forcast5day` that always returns a list of 5 objects with a particular
structure.
#### `no-frills`
`'no-frills'` is a simple - "no frills" (nothing extra) - implementation of
the `hello-world` interface. All it does is return the string:
```javascript
`Hello, ${subject ?? 'World'}!`
```
#### `'greet'`
`greet` is the method being called. It's the only method on the `hello-world`
interface.
#### `{ subject: 'Dave' }`
These are the arguments to the `greet` method. The arguments specify that we
want to say "Hello" to Dave. Hopefully he doesn't ask us to open the pod bay
doors, or if he does we hopefully have extensions to add a driver interface
and driver implementation for the pod bay doors so that we can interact with
them.
### Drivers in Extensions
The `hellodriver` extension adds the `hello-world` interface like this:
```javascript
extension.on('create.interfaces', event => {
// createInterface is the only method on this `event`
event.createInterface('hello-world', {
description: 'Provides methods for generating greetings',
methods: {
greet: {
description: 'Returns a greeting',
parameters: {
subject: {
type: 'string',
optional: true
},
locale: {
type: 'string',
optional: true
},
}
}
}
})
});
```
The `hellodriver` extension adds the `no-frills` implementation for
`hello-world` like this:
```javascript
extension.on('create.drivers', event => {
event.createDriver('hello-world', 'no-frills', {
greet ({ subject }) {
return `Hello, ${subject ?? 'World'}!`;
}
});
});`
```
You can pass an instance of a class for a driver implementation as well:
```javascript
class Greeter {
greet ({ subject }) {
return `Hello, ${subject ?? 'World'}!`;
}
}
extension.on('create.drivers', event => {
event.createDriver('hello-world', 'no-frills', new Greeter());
});`
```
Instances of classes being supported
may seem to be implied by the example before this
one, but that is not the case. What's shown here is that function members
of the object passed to `createDriver` will not be "bound" (have their
`.bind()` method called with a different object as the instance variable).
### Permission Denied
When you try to access a driver as any user other than the default
`admin` user, it will not work unless permission has been granted.
The `hellodriver` extension grants permission to all clients using
the following snippet:
```javascript
extension.on('create.permissions', event => {
event.grant_to_everyone('service:no-frills:ii:hello-world');
});
```
The `create.permissions` event's `event` object has a few methods
you can use depending on the desired granularity:
- `grant_to_everyone` - grants permission to all users
- `grant_to_users` - grants permission to only registered users
(i.e. not to temporary/guest users)
================================================
FILE: src/backend/doc/extensions/pages/import-and-export.md
================================================
## Extensions - Importing & Exporting
Here are two extensions. One extension has an "extension export" (an export to
other extensions) and an "extension import" (an import from another extension).
This is different from regular `import` or `require()` because it resolves to
a Puter extension loaded at runtime rather than an `npm` module.
To import and export in Puter extensions, we use `extension.import()` and `extension.exports`.
`exports-something.js`
```javascript
//@puter priority -1
// ^ setting load priority to "-1" allows other extensions to import
// this extension's exports before the initialization event occurs
// Just like "module.exports", but for extensions!
extension.exports = {
test_value: 'Hello, extensions!',
};
```
`imports-something.js`
```javascript
const { test_value } = extension.import('exports-something');
console.log(test_value); // 'Hello, extensions!'
```
================================================
FILE: src/backend/doc/extensions/pages/runtime-modules.md
================================================
## Extensions - Runtime Modules
Runtime modules are modules that extensions can import with tihs syntax:
```javascript
const somelib = extension.import('somelib');
```
These modules are registered in the [runtime module registry](../../../src/extension/RuntimeModuleRegistry.js)
which is instantiated by [Kernel.js](../../../src/Kernel.js).
All extensions implicitly have a Runtime Module. The runtime module shares the name
of the extension that it corresponds to. Extensions can export to their module by
using `extension.exports`:
```javascript
extension.exports = { /* ... */ };
```
The [Extension](../../../src/Extension.js) object proxies this call to the
runtime module (called `this.runtime` in the snippet):
```javascript
class Extension extends AdvancedBase {
// ...
set exports (value) {
this.runtime.exports = value;
}
// ...
}
```
You may be wondering why RuntimeModule is a separate class from Extension,
rather than just registering extensions into this registry.
Separating RuntimeModule allows core code that has not yet been migrated
to extensions to export values as if they came from extensions.
Since core modules are loaded before extensions, this allows any legacy
`useapi` definitions be be exported where modules are installed.
For example, in [CoreModule.js](../../../src/CoreModule.js) this snippet
of code is used to add a runtime module called `core`:
```javascript
// Extension compatibility
const runtimeModule = new RuntimeModule({ name: 'core' });
context.get('runtime-modules').register(runtimeModule);
runtimeModule.exports = useapi.use('core');
```
================================================
FILE: src/backend/doc/features/batch-and-symlinks.md
================================================
# Batch and Symlinks
2024-10-08
### Batch and Symlinks
All filesystem operations will eventually be available through batch requests.
Since batch requests can also handle the cases for single files, it seems silly
to support those endpoints too, so eventually most calls will be done through
`/batch`. Puter's legacy filesystem endpoints will always be supported, but a
future `api.___/fs/v2.0` urlspace for the filesystem API might not include them.
This is batch:
```javascript
await (async () => {
const endpoint = 'http://api.puter.localhost:4100/batch';
const ops = [
{
op: 'mkdir',
path: '/default_user/Desktop/some-dir',
},
{
op: 'write',
path: '/default_user/Desktop/some-file.txt',
}
];
const blob = new Blob(["12345678"], { type: 'text/plain' });
const formData = new FormData();
for ( const op of ops ) {
formData.append('operation', JSON.stringify(op));
}
formData.append('fileinfo', JSON.stringify({
name: 'file.txt',
size: 8,
mime: 'text/plain',
}));
formData.append('file', blob, 'hello.txt');
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Authorization': `Bearer ${puter.authToken}` },
body: formData
});
return await response.json();
})();
```
Symlinks are also created via `/batch`
```javascript
await (async () => {
const endpoint = 'http://api.puter.localhost:4100/batch';
const ops = [
{
op: 'symlink',
path: '~/Desktop',
name: 'link',
target: '/bb/Desktop/some'
},
];
const formData = new FormData();
for ( const op of ops ) {
formData.append('operation', JSON.stringify(op));
}
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Authorization': `Bearer ${puter.authToken}` },
body: formData
});
return await response.json();
})();
```
================================================
FILE: src/backend/doc/features/protected-apps.md
================================================
# Protected Apps and Subdomains
## Protected Sites
If a site is not protected, anyone can access the site.
When a site is protected, the following changes:
- The site can only be accessed inside a Puter app iframe
- Only users with explicit permission will be able to load
the page associated with the site.
## Protected Apps
If an app is not protected, anyone with the name of the
app or its UUID will be able to access the app.
If the app is **approved for listing** (todo: doc this)
all users can access the app.
If an app is protected, the following changes:
- The app can only be "seen" (listed) by users
with explicit permission.
- App metadata can only be accessed by users
with explicit permission.
Note that an app being protected does not imply that the
site is protected. If a user action results in an app
being protected it should also result in the site (subdomain)
being protected **if they own it**. If the site will not
be protected the user should have some indication.
================================================
FILE: src/backend/doc/features/service-scripts.md
================================================
> **NOTICE:** This documentation is new and might contain errors.
> Feel free to open a Github issue if you run into any problems.
# Service Scripts
## What is a Service Script?
Service scripts allow backend services to provide client-side code that
runs in Puter's GUI. This is useful if you want to make a mod or plugin
for Puter that has backend functionality. For example, you might want
to add a tab to the settings panel to make use of or configure the service.
Service scripts are made possible by the `puter-homepage` service, which
allows you to register URLs for additional javascript files Puter's
GUI should load.
## ES Modules - A Problem of Ordering
In browsers, script tags with `type=module` implicitly behave according
to those with the `defer` attribute. This means after the DOM is loaded
the scripts will run in the order in which they appear in the document.
Relying on this execution order however does not work. This is because
`import` is implicitly asynchronous. Effectively, this means these
scripts will execute in arbitrary order if they all have imports.
In a situation where all the client-side code is bundled with rollup
or webpack this is not an issue as you typically only have one
entry script. To facilitate loading service scripts, which are not
bundled with the GUI, we require that service scripts call the global
`service_script` function to access the API for service scripts.
## Providing a Service Script
For a service to provide a service script, it simply needs to serve
static files (the "service script") on some URL, and register that
URL with the `puter-homepage` service.
In this example below we use builtin functionality of express to serve
static files.
```javascript
class MyService extends BaseService {
async _init () {
// First we tell `puter-homepage` that we're going to be serving
// a javascript file which we want to be included when the GUI
// loads.
const svc_puterHomepage = this.services.get('puter-homepage');
svc_puterHomepage.register_script('/my-service-script/main.js');
}
async ['__on_install.routes'] (_, { app }) {
// Here we ask express to serve our script. This is made possible
// by WebServerService which provides the `app` object when it
// emits the 'install.routes` event.
app.use('/my-service-script',
express.static(
PathBuilder.add(__dirname).add('gui').build()
)
);
}
}
```
## A Simple Service Script
```javascript
import SomeModule from "./SomeModule.js";
service_script(api => {
api.on_ready(() => {
// This callback is invoked when the GUI is ready
// We can use api.get() to import anything exposed to
// service scripts by Puter's GUI; for example:
const Button = api.use('ui.components.Button');
// ^ Here we get Puter's Button component, which is made
// available to service scripts.
});
});
```
## Adding a Settings Tab
Starting with the following example:
```javascript
import MySettingsTab from "./MySettingsTab.js";
globalThis.service_script(api => {
api.on_ready(() => {
const svc_settings = globalThis.services.get('settings');
svc_settings.register_tab(MySettingsTab(api));
});
});
```
The module **MySettingsTab** exports a function for scoping the `api`
object, and that function returns a settings tab. The settings tab is
an object with a specific format that Puter's settings window understands.
Here are the contents of `MySettingsTab.js`:
```javascript
import MyWindow from "./MyWindow.js";
export default api => ({
id: 'my-settings-tab',
title_i18n_key: 'My Settings Tab',
icon: 'shield.svg',
factory: () => {
const NotifCard = api.use('ui.component.NotifCard');
const ActionCard = api.use('ui.component.ActionCard');
const JustHTML = api.use('ui.component.JustHTML');
const Flexer = api.use('ui.component.Flexer');
const UIAlert = api.use('ui.window.UIAlert');
// The root component for our settings tab will be a "flexer",
// which by default displays its child components in a vertical
// layout.
const component = new Flexer({
children: [
// We can insert raw HTML as a component
new JustHTML({
no_shadow: true, // use CSS for settings window
html: 'Some Heading ',
}),
new NotifCard({
text: 'I am a card with some text',
style: 'settings-card-success',
}),
new ActionCard({
title: 'Open an Alert',
button_text: 'Click Me',
on_click: async () => {
// Here we open an example window
await UIAlert({
message: 'Hello, Puter!',
});
}
})
]
});
return component;
}
});
```
================================================
FILE: src/backend/doc/howto_make_driver.md
================================================
# How to Make a Puter Driver
## What is a Driver?
A driver can be one of two things depending on what you're
talking about:
- a **driver interface** describes a general type of service
and what its parameters and result look like.
For example, `puter-chat-completion` is a driver interface
for AI Chat services, and it specifies that any service
on Puter for AI Chat needs a method called `complete` that
accepts a JSON parameter called `messages`.
- a **driver implementation** exists when a **Service** on
Puter implements a **trait** with the same name as a
driver interface.
## Part 1: Choose or Create a Driver Interface
Available driver interfaces exist at this location in the repo:
[/src/backend/src/services/drivers/interfaces.js](../src/services/drivers/interfaces.js).
When creating a new Puter driver implementation, you should check
this file to see if there's an appropriate interface. We're going
to make a driver that returns greeting strings, so we can use the
existing `hello-world` interface. If there wasn't an existing
interface, it would need to be created. Let's break down this
interface:
```javascript
'hello-world': {
description: 'A simple driver that returns a greeting.',
methods: {
greet: {
description: 'Returns a greeting.',
parameters: {
subject: {
type: 'string',
optional: true,
},
},
result: { type: 'string' },
}
}
},
```
The **description** describes what the interface is for. This
should be provided that both driver developers and users can
quickly identify what types of services should use it.
The **methods** object should have at least one entry, but it
may have more. The key of each entry is the name of a method;
in here we see `greet`. Each method also has a description,
a **parameters** object, and a **result** object.
The **parameters** object has an entry for each parameter that
may be passed to the method. Each entry is an object with a
`type` property specifying what values are allowed, and possibly
an `optional: true` entry.
All methods for Puter drivers use _named parameters_. There are no
positional parameters in Puter driver methods.
The **result** object specifies the type of the result. A service
called DriverService will use this to determine the response format
and headers of the response.
## Part 2: Create a Service
Creating a service is very easy, provided the service doesn't do
anything. Simply add a class to `src/backend/src/services` or into
the module of your choice (`src/backend/src/modules/`)
that looks like this:
```javascript
const BaseService = require('./BaseService')
// NOTE: the path specified ^ HERE might be different depending
// on the location of your file.
class PrankGreetService extends BaseService {
}
```
Notice I called the service "PrankGreet". This is a good service
name because you already know what the service is likely to
implement: this service generates a greeting, but it is a greeting
that intends to play a prank on whoever is beeing greeted.
Then, register the service into a module. If you put the service
under `src/backend/src/services`, then it goes in
[CoreModule](..//src/CoreModule.js) somewhere near the end of
the `install()` method. Otherwise, it will go in the `*Module.js`
file in the module where you placed your service.
The code to register the service is two lines of code that will
look something like this:
```javascript
const { PrankGreetServie } = require('./path/to/PrankGreetServie.js');
services.registerService('prank-greet', PrankGreetServie);
```
## Part 3: Verify that the Service is Registered
It's always a good idea to verify that the service is loaded
when starting Puter. Otherwise, you might spend time trying to
determine why your code doesn't work, when in fact it's not
running at all to begin with.
To do this, we'll add an `_init` handler to the service that
logs a message after a few seconds. We wait a few seconds so that
any log noise from boot won't bury our message.
```javascript
class PrankGreetService extends BaseService {
async _init () {
// Wait for 5 seconds
await new Promise(rslv => setTimeout(rslv), 5000);
// Display a log message
console.debug('Hello from PrankGreetService!');
}
}
```
## Part 4: Implement the Driver Interface in your Service
Now that it has been verified that the service is loaded, we can
start implementing the driver interface we chose eralier.
```javascript
class PrankGreetService extends BaseService {
async _init () {
// ... same as before
}
// Now we add this:
static IMPLEMENTS = {
['hello-world']: {
async greet ({ subject }) {
if ( subject ) {
return `Hello ${subject}, tell me about updog!`;
}
return `Hello, tell me about updog!`;
}
}
}
}
```
## Part 5: Test the Driver Implementation
We have now created the `prank-greet` implementation of `hello-world`.
Let's make a request in the browser to check it out. The example below
is a `fetch` call using `http://api.puter.localhost:4100` as the API
origin, which is the default when you're running Puter's backend locally.
Also, in this request I refer to `puter.authToken`. If you run this
snippet in the Dev Tools window of your browser from a tab with Puter
open (your local Puter, to be precise), this should contain the current
value for your auth token.
```javascript
await (await fetch("http://api.puter.localhost:4100/drivers/call", {
"headers": {
"Content-Type": "application/json",
"Authorization": `Bearer ${puter.authToken}`,
},
"body": JSON.stringify({
interface: 'hello-world',
service: 'prank-greet',
method: 'greet',
args: {
subject: 'World',
},
}),
"method": "POST",
})).json();
```
**You might see a permissions error!** Don't worry, this is expected;
in the next step we'll add the required permissions.
## Part 6: Permissions
In the previous step, you will only have gotten a successful response
if you're logged in as the `admin` user. If you're logged in as another
user you won't have access to the service's driver implementations be
default.
To grant permission for all users, update
[hardcoded-permissions.js](../src/data/hardcoded-permissions.js).
First, look for the constant `hardcoded_user_group_permissions`.
Whereever you see an entry for `service:hello-world:ii:hello-world`, add
the corresponding entry for your service, which will be called
```
service:prank-greet:ii:hello-world
```
To help you remember the permission string, its helpful to know that
`ii` in the string stands for "invoke interface". i.e. the scope of the
permission is under `service:prank-greet` (the `prank-greet` service)
and we want permission to invoke the interface `hello-world` on that
service.
You'll notice each entry in `hardcoded_user_group_permissions` has a value
determined by a call to the utility function `policy_perm(...)`. The policy
called `user.es` is a permissive policy for storage drivers, and we can
re-purpose it for our greeting implementor.
The policy of a permission determines behavior like rate limiting. This is
an advanced topic that is not covered in this guide.
If you want apps to be able to access the driver implementation without
explicit permission from a user, you will need to also register it in the
`default_implicit_user_app_permissions` constant. Additionally, you can
use the `implicit_user_app_permissions` constant to grant implicit
permission to the builtin Puter apps only.
Permissions to implementations on services can also be granted at runtime
to a user or group of users using the permissions API. This is beyond the
scope of this guide.
## Part 7: Verify Successful Response
If all went well, you should see the response in your console when you
try the request from Part 5. Try logging into a user other than `admin`
to verify permisison is granted.
```json
"Hello World, tell me about updog!"
```
## Part 8: Next Steps
- [Access Configuration](./services/config.md)
- [Output Logs](./services/log.md)
- [Add HTTP Routes](./services/http.md)
================================================
FILE: src/backend/doc/license_header.txt
================================================
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 .
================================================
FILE: src/backend/doc/lists-of-things/list-of-permissions.md
================================================
# Permissions
## Filesystem Permissions
### `fs::`
- `` specifies the file that this permission
is associated with.
The ACL service
(which checks filesystem permissions)
knows if the value is a path or UUID based on the presence
of a leading slash; if it starts with `"/"` it's a path.
- `` specifies one of:
`write`, `read`, `list`, `see`; where each item in that
list implies all the access levels which follow.
- A permission that grants access to a directory,
such as `/user/shared`, implies access
of the same **access level** to all child file or directory
nodes under that location, **recursively**;
`fs:/user/shared:read` implies `fs:/user/shared/nested/file.txt:read`
- The "real" permission is `fs::`;
whenever path is specified the permission is rewritten.
**note:** future support for other filesystems
could make this rewrite rule conditional.
## App and Subdomain permissions
### `site::access`
- `` specifies the subdomain that this
permission is associated with.
Here, "subdomain" means the **"name of the subdomain"**,
which means a site accessed via `my-name.example.site`
will be specified here with `my-name`.
- This permission is always rewritten as the permission
described below (backend does this automatically).
### `site:uid#:access`
- If the subdomain is **not** [protected](../features/protected-apps.md),
this permission is ignored by the system.
- If the subdomain **is** protected, this permission will
allow access to the site via a Puter app iframe with
a token for the entity to which permission was granted
### `app::access`
- `` specifies the app that this
permission is associated with.
- This permission is always rewritten as the permission
described below (backend does this automatically).
### `app:uid#:access`
- If the app is **not** [protected](../features/protected-apps.md),
this permission is ignored by the system.
- If the app **is** protected, this permission will
allow reading the app's metadata and seeing that the app exists.
================================================
FILE: src/backend/doc/lists-of-things/list-of-tto-types.md
================================================
# Types for Type-Tagged Objects
## Internal Use
### `{ $: 'share-intent' }`
- Used in the `/share` endpoint
- Permissions get applied to existing users
- For email shares, is trasnformed into a `token:share`
which is stored in the `share` database table.
- **variants:**
- `share-intent:file`
- `share-intent:app`
- **properties:**
- `permissions` - a list of permissions to grant
### `{ $: 'internal:share' }`
- Stored in the `share` database table
- **properties:**
- `permissions` - a list of permissions to grant
### `{ $: 'token:share }`
- Stored in a JWT called the "share token"
- Contains only the share UUID
- **properties:**
- `uid` - UUID of a share
================================================
FILE: src/backend/doc/log_config.md
================================================
## Backend - Configuring Logs
### Log visibility specified by configuration file
The configuration file can define an array parameter called `logging`.
This configures the visibility of specific logs in core areas based on
which string flags are present.
For example, the following configuration enables HTTP request logs:
```json
{
"logging": ['http']
}
```
Sometimes "enabling" a log means moving its log level from `debug` to `info`.
#### Available logging flags:
- `http`: http requests
- `fsentries-not-found`: information about files that were stat'd but weren't there
#### Other log options
- Setting `log_upcoming_alarms` to `true` will log alarms before they are created.
This would be useful if AlarmService itself is failing.
- Setting `trace_logs` to `true` will display a stack trace below every log message.
This can be useful if you don't know where a particular log is coming from and
want to track it down.
#### Service-level log configuration
Services can be configured to change their logging behavior. Services will have one of
two behaviors:
1. **info logging** - `log.info` can be used to create an `[INFO]` log message
2. **debug logging only** - `log.info` is redirected to `log.debug`
Services will have **info logging** enabled by default, unless the class definition
has the static member `static LOG_DEBUG = true` (in which case **debug logging only**
is the default).
In a service's configuration block the desired behavior can be specified by setting
either `"log_debug": true` or `"log_info": true`
================================================
FILE: src/backend/doc/modules/filesystem/API_SPEC.md
================================================
# Filesystem API
Filesystem endpoints allow operations on files and directories in the Puter filesystem.
## POST `/mkdir` (auth required)
### Description
Creates a new directory in the filesystem. Currently support 2 formats:
- Full path: `{"path": "/foo/bar", args ...}` — this API is used by apitest (`./tools/api-tester/apitest.js`) and aligns more closely with the POSIX spec (https://linux.die.net/man/3/mkdir)
- Parent + path: `{"parent": "/foo", "path": "bar", args ...}` — this API is used by `puter-js` via `puter.fs.mkdir`
A future work would be use a unified format for all filesystem operations.
### Parameters
- **path** _- required_
- **accepts:** `string`
- **description:** The path where the directory should be created
- **notes:** Cannot be empty, null, or undefined
- **parent** _- optional_
- **accepts:** `string | UUID`
- **description:** The parent directory path or UUID
- **notes:** If not provided, path is treated as full path
- **overwrite** _- optional_
- **accepts:** `boolean`
- **default:** `false`
- **description:** Whether to overwrite existing files/directories
- **dedupe_name** _- optional_
- **accepts:** `boolean`
- **default:** `false`
- **description:** Whether to automatically rename if name exists
- **create_missing_parents** _- optional_
- **accepts:** `boolean`
- **default:** `false`
- **description:** Whether to create parent directories if they don't exist
- **aliases:** `create_missing_ancestors`
- **shortcut_to** _- optional_
- **accepts:** `string | UUID`
- **description:** Creates a shortcut/symlink to the specified target
### Example
```json
{
"path": "/user/Desktop/new-directory"
}
```
```json
{
"parent": "/user",
"path": "Desktop/new-directory"
}
```
### Response
Returns the created directory's metadata including name, path, uid, and any parent directories created.
## Other Filesystem Endpoints
[Additional endpoints would be documented here...]
================================================
FILE: src/backend/doc/modules/puterai/README.md
================================================
# PuterAI Module
The PuterAI module provides AI capabilities to Puter through various services including:
- Text generation and chat completion
- Text-to-speech synthesis
- Image generation
- Document analysis
## Metered Services
All AI services in this module are metered using Puter's MeteringService. This allows us to charge per `unit` usage, where a `unit` is defined by the specific service:
for example, most LLMs will charge per token, AWS Polly charges per character, and AWS Textract charges per page. the metering service tracks usage units, and relies on its centralized cost maps to determine if a user has enough credits to perform an operation, and to record usage after the operation is complete.
see [MeteringService](../../../src/services/MeteringService/MeteringService.ts) for more details on how metering works.
================================================
FILE: src/backend/doc/notes/2024-10-03_email_in_use_checks.md
================================================
## 2024-10-03
### Plan (constantly changing as per what's below)
- `signup.js` only says "email already used" if the one that's
already been used is confirmed.
- "change email" needs to follow the same logic; show an error when
an email already exists on an account with a confirmed email.
Then, upon confirming the update, Ensure that in the meanwhile no
new account came up with that email set.
- ensure `clean_email` is updated whenever the email is updated
### Email duplicate check on confirmation
- signup.js:149 -> this is where email dupe is currently checked
- signup.js:290 -> This is where we send the confirmation email.
There is also a branch that sends a "confirm token".
I don't recall what this is for.
### Investigating the "confirm token"
- email template is `email_verification_code`
instead of `email_verification_link`
- This happens when either:
- user.requires_email_confirmation is TRUE
- send_confirmation_code is TRUE in REQUEST
### Figuring out when `requires_email_confirmation` is TRUE
I'm mostly curious about this state on a user.
It's strange that `signup.js` would do anything on EXISTING users.
1. `pseudo_user` may be populated if `req.body.email` exists
AND a user with no password exists with that email
2. `uuid_user` may be populated if a user exists with the specified
UUID, but it has no usefulness unless `uuid_user` has the same
id as `pseudo_user`.
`uuid_user` is only used to set `email_confirmation_required` to 0
IFF `pseudo_user` has same id as `uuid_user`
AND `psuedo_user` has an email
When does `pseudo_user` have an email?
### Figuring out when a pseudo user can have an email
- asking NJ, I'm at a loss on this one for the moment
### Figuring out if account takeover is possible on signup.js with a uuid
- Nope, looks like `uuid_user` is only used to set
`email_confirmation_required = 0`
### Figuring out when `send_confirmation_code` is TRUE in REQUEST
- IFF `require_email_verification_to_publish_website` is TRUE
- it's not currently, but we need this to be possible to enable
- ^ That seems to be the ONLY place when this matters
### Current Thoughts
- `email_verification_code` will be difficult to test because there is
nothing currently in the system that's using it. However, I could try
enabling `require_email_verification_to_publish_website` locally and
see if this behavior begins to work as expected.
- `email_verification_link` where we can confirm an email. If another email
was already confirmed since the time the link was sent, we need to display
an error message to the user.
### Find places where (on backend) email change process is triggered
Right now there are two handlers:
- `/user-protected/change-email` (UserProtectedEndpointsService)
- Invokes the process (sends confirmation email)
- `/change_email/confirm` (PuterAPIService)
- Endpoint that the email link points to
================================================
FILE: src/backend/doc/services/config.md
================================================
# Service Configuration
To locate your configuration file, see [Configuring Puter](https://github.com/HeyPuter/puter/wiki/self_hosters-config).
### Accessing Service Configuration
Service configuration appears under the `"services"` property in the
configuration file for Puter. If Puter's configuration had no other
values except for a service config with one key, it might look like
this:
```json
{
"services": {
"my-service": {
"somekey": "some value"
}
}
}
```
Services have their configuration object assigned to `this.config`.
```javascript
class MyService extends BaseService {
async _init () {
// You can access configuration for a service like this
this.log.info('value of my key is: ' + this.config.somekey);
}
}
```
### Accessing Global Configuration
Services can access global configuration. This can be useful for knowing how
Puter itself is configured, but using this global config object for service
configuration is discouraged as it could create conflicts between services.
```javascript
class MyService extends BaseService {
async _init () {
// You can access configuration for a service like this
this.log.info('Puter is hosted on: ' + this.global_config.domain);
}
}
```
================================================
FILE: src/backend/doc/services/event_buses.md
================================================
# Event Buses
Puter's backend has two event buses:
- Service Event Bus
- Application Event Bus
## Service Event Bus
This is a simple event bus that lives in the [Container](../../src/services/Container.js)
class. There is only one instance of **Container** and it is called the "services container".
When Puter boots, all the services registered by modules are registered into the services
container.
Services handle events from the Service Event Bus by implementing methods which are named
with the prefix `__on_`. This prefix looks a little strange at first so it's worth
breaking it down:
- `__` (two underscores) prevents collision with common method names, and also
common conventions like beginning a method name with a single underscore
to indicate a method that should be overridden.
- `on` is the meaningful name.
- `_`, the last underscore, is for readability, as the event name conventionally
begins with a lowercase letter.
Note that you will need to use the
Example:
```javascript
class MyService extends BaseService {
['__on_boot.ready'] () {
//
}
}
```
================================================
FILE: src/backend/doc/services/http.md
================================================
# Adding HTTP Routes to Services
Services can serve HTTP routes when the [WebModule](../../src/modules/web/WebModule.js)
is enabled by listening for the `install.routes` event on the [Service Event Bus](./)
================================================
FILE: src/backend/doc/services/log.md
================================================
# Logging in Services
# NOTE: You can, and maybe should, just use console log methods, as they are overriden to log through our logger
Services all have a logger available at `this.log`.
```javascript
class MyService extends BaseService {
async init () {
this.log.info('Hello, Logger!');
}
}
```
There are multiple "log levels", similar to `logrus` or other common logging
libraries.
```javascript
class MyService extends BaseService {
async init () {
this.log.info('I\'m just a regular log.');
this.log.debug('I\'m only for developers.');
this.log.warn('It is statistically unlikely I will be awknowledged.');
this.log.error('Something is broken! Pay attention!');
this.log.noticeme('This will be noticed, unlike warnings. Use sparingly.');
this.log.system('I am a system event, like shutdown.');
this.log.tick('A periodic behavior like cache pruning is occurring.');
}
}
```
Log methods can take a second parameter, an object specifying fields.
```javascript
class MyService extends BaseService {
async init () {
this.log.info('I have fields!', {
why: "why not",
random_number: 1, // chosen by coin toss, guarenteed to be random
});
}
}
```
================================================
FILE: src/backend/exports.js
================================================
/*
* 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 .
*/
import CoreModule from './src/CoreModule.js';
import DatabaseModule from './src/DatabaseModule.js';
import { testlaunch } from './src/index.js';
import { Kernel } from './src/Kernel.js';
import LocalDiskStorageModule from './src/LocalDiskStorageModule.js';
import MemoryStorageModule from './src/MemoryStorageModule.js';
import { PuterAIModule } from './src/modules/ai/PuterAIChatModule.js';
import { AppsModule } from './src/modules/apps/AppsModule.js';
import { BroadcastModule } from './src/modules/broadcast/BroadcastModule.js';
import { CaptchaModule } from './src/modules/captcha/CaptchaModule.js';
import { Core2Module } from './src/modules/core/Core2Module.js';
import { DataAccessModule } from './src/modules/data-access/DataAccessModule.js';
import { DevelopmentModule } from './src/modules/development/DevelopmentModule.js';
import { DNSModule } from './src/modules/dns/DNSModule.js';
import { DomainModule } from './src/modules/domain/DomainModule.js';
import { EntityStoreModule } from './src/modules/entitystore/EntityStoreModule.js';
import { HostOSModule } from './src/modules/hostos/HostOSModule.js';
import { InternetModule } from './src/modules/internet/InternetModule.js';
import { KVStoreModule } from './src/modules/kvstore/KVStoreModule.js';
import { PuterFSModule } from './src/modules/puterfs/PuterFSModule.js';
import SelfHostedModule from './src/modules/selfhosted/SelfHostedModule.js';
import { TestConfigModule } from './src/modules/test-config/TestConfigModule.js';
import { TestDriversModule } from './src/modules/test-drivers/TestDriversModule.js';
import { WebModule } from './src/modules/web/WebModule.js';
import BaseService from './src/services/BaseService.js';
import { Context } from './src/util/context.js';
export default {
helloworld: () => {
console.log('Hello, World!');
process.exit(0);
},
testlaunch,
// Kernel API
BaseService,
Context,
Kernel,
EssentialModules: [
Core2Module,
PuterFSModule,
HostOSModule,
CoreModule,
WebModule,
// TemplateModule,
AppsModule,
CaptchaModule,
EntityStoreModule,
KVStoreModule,
DataAccessModule,
],
// Pre-built modules
CoreModule,
WebModule,
DatabaseModule,
LocalDiskStorageModule,
MemoryStorageModule,
SelfHostedModule,
TestDriversModule,
TestConfigModule,
PuterAIModule,
BroadcastModule,
InternetModule,
CaptchaModule,
KVStoreModule,
DNSModule,
DomainModule,
// Development modules
DevelopmentModule,
};
================================================
FILE: src/backend/package.json
================================================
{
"name": "@heyputer/backend",
"version": "2.5.1",
"description": "Backend/Kernel for Puter",
"main": "exports.js",
"scripts": {
"test": "npx mocha src/**/*.test.js && node ./tools/test.mjs",
"bench": "vitest bench --config=vitest.bench.config.ts --run",
"build:worker": "cd src/services/worker && npm run build"
},
"dependencies": {
"@aws-sdk/client-cloudwatch": "^3.940.0",
"@aws-sdk/client-polly": "^3.622.0",
"@aws-sdk/client-textract": "^3.621.0",
"@google/generative-ai": "^0.21.0",
"@heyputer/kv.js": "^0.1.9",
"@heyputer/multest": "^0.0.2",
"@heyputer/putility": "^1.0.0",
"@mistralai/mistralai": "^1.3.4",
"@opentelemetry/api": "^1.4.1",
"@opentelemetry/auto-instrumentations-node": "^0.43.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.40.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.40.0",
"@opentelemetry/sdk-metrics": "^1.14.0",
"@opentelemetry/sdk-node": "^0.49.1",
"@pagerduty/pdjs": "^2.2.4",
"@smithy/node-http-handler": "^2.2.2",
"@socket.io/redis-streams-adapter": "^0.3.1",
"args": "^5.0.3",
"axios": "^1.8.2",
"bcrypt": "^5.1.0",
"better-sqlite3": "^12.6.0",
"busboy": "^1.6.0",
"chai-as-promised": "^7.1.1",
"clean-css": "^5.3.2",
"composite-error": "^1.0.2",
"compression": "^1.7.4",
"convertapi": "^1.15.0",
"cookie-parser": "^1.4.6",
"dedent": "^1.5.3",
"dns2": "^2.1.0",
"express": "^4.18.2",
"file-type": "^21.3.3",
"firebase-admin": "^10.3.0",
"form-data": "^4.0.0",
"groq-sdk": "^0.5.0",
"handlebars": "^4.7.8",
"helmet": "^7.0.0",
"hi-base32": "^0.5.1",
"html-entities": "^2.3.3",
"ioredis": "^5.9.2",
"ioredis-mock": "^8.13.1",
"is-glob": "^4.0.3",
"isbot": "^3.7.1",
"jimp": "^1.6.0",
"js-sha256": "^0.9.0",
"json5": "^2.2.3",
"jsonwebtoken": "^9.0.0",
"knex": "^3.1.0",
"lorem-ipsum": "^2.0.8",
"lru-cache": "^11.0.2",
"micromatch": "^4.0.5",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"morgan": "^1.10.0",
"multer": "^2.0.2",
"multi-progress": "^4.0.0",
"murmurhash": "^2.0.1",
"music-metadata": "^11.12.3",
"nodemailer": "^7.0.7",
"on-finished": "^2.4.1",
"openai": "^6.7.0",
"otpauth": "9.2.4",
"prompt-sync": "^4.2.0",
"proxyquire": "^2.1.3",
"recursive-readdir": "^2.2.3",
"response-time": "^2.3.2",
"seedrandom": "^3.0.5",
"sharp": "^0.34.3",
"sharp-bmp": "^0.1.5",
"sharp-ico": "^0.1.5",
"shescape": "^2.1.10",
"socket.io": "^4.6.2",
"socket.io-client": "^4.6.2",
"ssh2": "^1.13.0",
"string-hash": "^1.1.3",
"string-length": "^6.0.0",
"svg-captcha": "^1.4.0",
"svgo": "^3.3.3",
"tiktoken": "^1.0.16",
"together-ai": "^0.33.0",
"tweetnacl": "^1.0.3",
"ua-parser-js": "^1.0.38",
"uglify-js": "^3.17.4",
"uuid": "^9.0.0",
"validator": "^13.9.0",
"winston": "^3.9.0",
"winston-daily-rotate-file": "^4.7.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/node": "^24.0.0",
"chai": "^4.3.7",
"jsdom": "29.0.0",
"mocha": "^7.2.0",
"nodemon": "^3.1.0",
"nyc": "^15.1.0",
"sinon": "^15.2.0",
"typescript": "^5.9.3",
"vitest": "^4.0.14"
},
"author": "Puter Technologies Inc.",
"license": "AGPL-3.0-only"
}
================================================
FILE: src/backend/src/CoreModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { NotificationES } = require('./om/entitystorage/NotificationES');
const { ProtectedAppES } = require('./om/entitystorage/ProtectedAppES');
const { Context } = require('./util/context');
const { LLOWrite } = require('./filesystem/ll_operations/ll_write');
const { LLRead } = require('./filesystem/ll_operations/ll_read');
const { RuntimeModule } = require('./extension/RuntimeModule.js');
const { TYPE_DIRECTORY, TYPE_FILE } = require('./filesystem/FSNodeContext.js');
const { TDetachable } = require('@heyputer/putility/src/traits/traits.js');
const { MultiDetachable } = require('@heyputer/putility/src/libs/listener.js');
const { OperationFrame } = require('./services/OperationTraceService');
const opentelemetry = require('@opentelemetry/api');
const query = require('./om/query/query');
const { redisClient } = require('./clients/redis/redisSingleton');
const { kv } = require('./util/kvSingleton');
/**
* @footgun - real install method is defined above
*/
const install = async ({ context, services, app, useapi, modapi }) => {
const config = require('./config');
const { TelemetryService } = require('./modules/perfmon/TelemetryService');
if ( ! services.has('telemetry') ) {
services.registerService('telemetry', TelemetryService);
}
// === LIBRARIES ===
useapi.withuse(() => {
def('Service', require('./services/BaseService'));
def('Module', AdvancedBase);
def('core.util.helpers', require('./helpers'));
def('core.util.permission', require('./services/auth/permissionUtils.mjs').PermissionUtil);
def('puter.middlewares.auth', require('./middleware/auth2'));
def('puter.middlewares.configurable_auth', require('./middleware/configurable_auth'));
def('puter.middlewares.anticsrf', require('./middleware/anticsrf'));
def('core.APIError', require('./api/APIError'));
def('core.Context', Context);
def('core', require('./services/auth/Actor'), { assign: true });
def('core', {
TDetachable,
MultiDetachable,
}, { assign: true });
def('core.config', config);
// Note: this is an incomplete export; it was added for a proprietary
// extension. Contributors may wish to add definitions in the 'fs.'
// scope. Needing to add these individually is possibly a symptom of an
// anti-pattern; "export filesystem operations to extensions" is one
// statement in English, so maybe it should be one statement of code.
def('core.fs', {
LLOWrite,
LLRead,
TYPE_DIRECTORY,
TYPE_FILE,
OperationFrame,
});
def('core.fs.selectors', require('./filesystem/node/selectors'));
def('core.util.stream', require('./util/streamutil'));
def('web', require('./util/expressutil'));
def('core.validation', require('./validation'));
def('core.database', require('./services/database/consts.js'));
def('core.redisClient', redisClient);
def('core.kvjs', kv);
// Add otelutil functions to `core.`
def('core.spanify', require('./util/otelutil').spanify);
def('core.abtest', require('./util/otelutil').abtest);
// Extension module: 'core'
{
const runtimeModule = new RuntimeModule({ name: 'core' });
context.get('runtime-modules').register(runtimeModule);
runtimeModule.exports = useapi.use('core');
}
{
const runtimeModule = new RuntimeModule({ name: 'query' });
context.get('runtime-modules').register(runtimeModule);
runtimeModule.exports = query;
}
// Extension module: 'tel'
{
const runtimeModule = new RuntimeModule({ name: 'tel' });
runtimeModule.exports = {
trace: opentelemetry.trace,
};
context.get('runtime-modules').register(runtimeModule);
}
});
modapi.libdir('core.util', './util');
// === SERVICES ===
// TODO: move these to top level imports or await imports and esm this file
const { CommandService } = require('./services/CommandService');
const { RateLimitService } = require('./services/sla/RateLimitService');
const { AuthService } = require('./services/auth/AuthService');
const { SLAService } = require('./services/sla/SLAService');
const { PermissionService } = require('./services/auth/PermissionService');
const { ACLService } = require('./services/auth/ACLService');
const { CoercionService } = require('./services/drivers/CoercionService');
const { PuterSiteService } = require('./services/PuterSiteService');
const { ContextInitService } = require('./services/ContextInitService');
const { IdentificationService } = require('./services/abuse-prevention/IdentificationService');
const { AuthAuditService } = require('./services/abuse-prevention/AuthAuditService');
const { RegistryService } = require('./services/RegistryService');
const { RegistrantService } = require('./services/RegistrantService');
const { SystemValidationService } = require('./services/SystemValidationService');
const { EntityStoreService } = require('./services/EntityStoreService');
const SQLES = require('./om/entitystorage/SQLES');
const ValidationES = require('./om/entitystorage/ValidationES');
const { SetOwnerES } = require('./om/entitystorage/SetOwnerES');
const AppES = require('./om/entitystorage/AppES');
const WriteByOwnerOnlyES = require('./om/entitystorage/WriteByOwnerOnlyES');
const SubdomainES = require('./om/entitystorage/SubdomainES');
const { MaxLimitES } = require('./om/entitystorage/MaxLimitES');
const { AppLimitedES } = require('./om/entitystorage/AppLimitedES');
const { ReadOnlyES } = require('./om/entitystorage/ReadOnlyES');
const { OwnerLimitedES } = require('./om/entitystorage/OwnerLimitedES');
const { ESBuilder } = require('./om/entitystorage/ESBuilder');
const { Eq, Or } = require('./om/query/query');
const { MakeProdDebuggingLessAwfulService } = require('./services/MakeProdDebuggingLessAwfulService');
const { ConfigurableCountingService } = require('./services/ConfigurableCountingService');
const { FSLockService } = require('./services/fs/FSLockService');
const FilesystemAPIService = require('./services/FilesystemAPIService');
const { ServeGUIService } = require('./services/ServeGUIService');
const PuterAPIService = require('./services/PuterAPIService');
const { RefreshAssociationsService } = require('./services/RefreshAssociationsService');
// Service names beginning with '__' aren't called by other services;
// these provide data/functionality to other services or produce
// side-effects from the events of other services.
// === Services which extend BaseService ===
const { DDBClientWrapper } = require('./clients/dynamodb/DDBClientWrapper');
services.registerService('dynamo', DDBClientWrapper);
services.registerService('system-validation', SystemValidationService);
services.registerService('commands', CommandService);
services.registerService('__api-filesystem', FilesystemAPIService);
services.registerService('__api', PuterAPIService);
services.registerService('__gui', ServeGUIService);
services.registerService('registry', RegistryService);
services.registerService('__registrant', RegistrantService);
services.registerService('fslock', FSLockService);
services.registerService('es:app', EntityStoreService, {
entity: 'app',
upstream: ESBuilder.create([
SQLES, { table: 'app', debug: true },
AppES,
AppLimitedES, {
permission_prefix: 'apps-of-user',
// When apps query es:apps, they're allowed to see apps which
// are approved for listing and they're allowed to see their
// own entry.
exception: async () => {
const actor = Context.get('actor');
return new Or({
children: [
new Eq({
key: 'approved_for_listing',
value: 1,
}),
new Eq({
key: 'uid',
value: actor.type.app.uid,
}),
],
});
},
},
WriteByOwnerOnlyES,
ValidationES,
SetOwnerES,
ProtectedAppES,
MaxLimitES, { max: 5000 },
]),
});
const { EntriService } = require('./services/EntriService.js');
services.registerService('entri-service', EntriService);
const { FilesystemService } = require('./filesystem/FilesystemService');
services.registerService('filesystem', FilesystemService);
services.registerService('es:subdomain', EntityStoreService, {
entity: 'subdomain',
upstream: ESBuilder.create([
SQLES, { table: 'subdomains', debug: true },
SubdomainES,
AppLimitedES, { permission_prefix: 'subdomains-of-user' },
WriteByOwnerOnlyES,
ValidationES,
SetOwnerES,
MaxLimitES, { max: 5000 },
]),
});
services.registerService('es:notification', EntityStoreService, {
entity: 'notification',
upstream: ESBuilder.create([
SQLES, { table: 'notification', debug: true },
NotificationES,
OwnerLimitedES,
ReadOnlyES,
SetOwnerES,
MaxLimitES, { max: 200 },
]),
});
services.registerService('rate-limit', RateLimitService);
services.registerService('auth', AuthService);
// services.registerService('preauth', PreAuthService);
services.registerService('permission', PermissionService);
services.registerService('sla', SLAService);
services.registerService('acl', ACLService);
services.registerService('coercion', CoercionService);
services.registerService('puter-site', PuterSiteService);
services.registerService('context-init', ContextInitService);
services.registerService('identification', IdentificationService);
services.registerService('auth-audit', AuthAuditService);
services.registerService('counting', ConfigurableCountingService);
services.registerService('__refresh-assocs', RefreshAssociationsService);
services.registerService('__prod-debugging', MakeProdDebuggingLessAwfulService);
const { EventService } = require('./services/EventService');
services.registerService('event', EventService);
const { PuterVersionService } = require('./services/PuterVersionService');
services.registerService('puter-version', PuterVersionService);
const { SessionService } = require('./services/SessionService');
services.registerService('session', SessionService);
const { EdgeRateLimitService } = require('./services/abuse-prevention/EdgeRateLimitService');
services.registerService('edge-rate-limit', EdgeRateLimitService);
const { CleanEmailService } = require('./services/CleanEmailService');
services.registerService('clean-email', CleanEmailService);
const { Emailservice } = require('./services/EmailService');
services.registerService('email', Emailservice);
const { TokenService } = require('./services/auth/TokenService');
services.registerService('token', TokenService);
const { OTPService } = require('./services/auth/OTPService');
services.registerService('otp', OTPService);
const { OIDCService } = require('./services/auth/OIDCService');
services.registerService('oidc', OIDCService);
const { SignupService } = require('./services/auth/SignupService');
services.registerService('signup', SignupService);
const { UserProtectedEndpointsService } = require('./services/web/UserProtectedEndpointsService');
services.registerService('__user-protected-endpoints', UserProtectedEndpointsService);
const { AntiCSRFService } = require('./services/auth/AntiCSRFService');
services.registerService('anti-csrf', AntiCSRFService);
const { LockService } = require('./services/LockService');
services.registerService('lock', LockService);
const { PuterHomepageService } = require('./services/PuterHomepageService');
services.registerService('puter-homepage', PuterHomepageService);
const { GetUserService } = require('./services/GetUserService');
services.registerService('get-user', GetUserService);
const { DetailProviderService } = require('./services/DetailProviderService');
services.registerService('whoami', DetailProviderService);
const { DriverService } = require('./services/drivers/DriverService');
services.registerService('driver', DriverService);
const { ScriptService } = require('./services/ScriptService');
services.registerService('script', ScriptService);
const { NotificationService } = require('./services/NotificationService');
services.registerService('notification', NotificationService);
const { ShareService } = require('./services/ShareService');
services.registerService('share', ShareService);
const { GroupService } = require('./services/auth/GroupService');
services.registerService('group', GroupService);
const { VirtualGroupService } = require('./services/auth/VirtualGroupService');
services.registerService('virtual-group', VirtualGroupService);
const { PermissionAPIService } = require('./services/PermissionAPIService');
services.registerService('__permission-api', PermissionAPIService);
const { AnomalyService } = require('./services/AnomalyService');
services.registerService('anomaly', AnomalyService);
const { HelloWorldService } = require('./services/HelloWorldService');
services.registerService('hello-world', HelloWorldService);
const { SystemDataService } = require('./services/SystemDataService');
services.registerService('system-data', SystemDataService);
const { SUService } = require('./services/SUService');
services.registerService('su', SUService);
const { ShutdownService } = require('./services/ShutdownService');
services.registerService('shutdown', ShutdownService);
const { BootScriptService } = require('./services/BootScriptService');
services.registerService('boot-script', BootScriptService);
const { FeatureFlagService } = require('./services/FeatureFlagService');
services.registerService('feature-flag', FeatureFlagService);
const { KernelInfoService } = require('./services/KernelInfoService');
services.registerService('kernel-info', KernelInfoService);
const { DriverUsagePolicyService } = require('./services/drivers/DriverUsagePolicyService');
services.registerService('driver-usage-policy', DriverUsagePolicyService);
const { ReferralCodeService } = require('./services/ReferralCodeService');
services.registerService('referral-code', ReferralCodeService);
const { VerifiedGroupService } = require('./services/VerifiedGroupService');
services.registerService('__verified-group', VerifiedGroupService);
const { UserService } = require('./services/UserService');
services.registerService('user', UserService);
const { WSPushService } = require('./services/WSPushService');
services.registerService('__event-push-ws', WSPushService);
const { SNSService } = require('./services/SNSService');
services.registerService('sns', SNSService);
const { WispService } = require('./services/WispService');
services.registerService('wisp', WispService);
// const { AWSSecretsPopulator } = require('./services/AWSSecretsPopulator.js');
// services.registerService('awsthing', AWSSecretsPopulator);
const { WebDavFS } = require('./services/WebDAV/WebDAVService.js');
services.registerService('dav', WebDavFS);
const { RequestMeasureService } = require('./services/RequestMeasureService');
services.registerService('request-measure', RequestMeasureService);
const { ChatAPIService } = require('./services/ChatAPIService');
services.registerService('__chat-api', ChatAPIService);
const { WorkerService } = require('./services/worker/WorkerService');
services.registerService('worker-service', WorkerService);
const { MeteringServiceWrapper } = require('./services/MeteringService/MeteringServiceWrapper.mjs');
services.registerService('meteringService', MeteringServiceWrapper);
const { DynamoKVStoreWrapper } = require('./services/DynamoKVStore/DynamoKVStoreWrapper.js');
services.registerService('puter-kvstore', DynamoKVStoreWrapper);
const { PermissionShortcutService } = require('./services/auth/PermissionShortcutService');
services.registerService('permission-shortcut', PermissionShortcutService);
const { PeerService } = require('./services/PeerService');
services.registerService('peer', PeerService);
};
const install_legacy = async ({ services }) => {
const { OperationTraceService } = require('./services/OperationTraceService');
const { ClientOperationService } = require('./services/ClientOperationService');
const { EngPortalService } = require('./services/EngPortalService');
// === Services which do not yet extend BaseService ===
// services.registerService('filesystem', FilesystemService);
services.registerService('operationTrace', OperationTraceService);
services.registerService('client-operation', ClientOperationService);
services.registerService('engineering-portal', EngPortalService);
};
/**
* Core module for the Puter platform that includes essential services including
* authentication, filesystems, rate limiting, permissions, and various API endpoints.
*
* This is a monolithic module. Incrementally, services should be migrated to
* Core2Module and other modules instead. Core2Module has a smaller scope, and each
* new module will be a cohesive concern. Once CoreModule is empty, it will be removed
* and Core2Module will take on its name.
*/
class CoreModule extends AdvancedBase {
dirname () {
return __dirname;
}
async install (context) {
const services = context.get('services');
const app = context.get('app');
const useapi = context.get('useapi');
const modapi = context.get('modapi');
await install({ context, services, app, useapi, modapi });
}
/**
* Installs legacy services that don't extend BaseService and require special handling.
* These services were created before the BaseService class existed and don't listen
* to the init event. They need to be installed after the init event is dispatched
* due to initialization order dependencies.
*
* @param {Object} context - The context object containing service references
* @param {Object} context.services - Service registry for registering legacy services
* @returns {Promise} Resolves when legacy services are installed
*/
async install_legacy (context) {
const services = context.get('services');
await install_legacy({ services });
}
}
module.exports = CoreModule;
================================================
FILE: src/backend/src/DatabaseModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
class DatabaseModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const { StrategizedService } = require('./services/StrategizedService');
const { SqliteDatabaseAccessService } = require('./services/database/SqliteDatabaseAccessService');
services.registerService('database', StrategizedService, {
strategy_key: 'engine',
strategies: {
sqlite: [SqliteDatabaseAccessService],
},
});
}
}
module.exports = DatabaseModule;
================================================
FILE: src/backend/src/Extension.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const EmitterFeature = require('@heyputer/putility/src/features/EmitterFeature');
const { Context } = require('./util/context');
const { ExtensionServiceState } = require('./ExtensionService');
const module_epoch_d = new Date();
const display_time = (now) => {
const pad2 = n => String(n).padStart(2, '0');
const yyyy = now.getFullYear();
const mm = pad2(now.getMonth() + 1);
const dd = pad2(now.getDate());
const HH = pad2(now.getHours());
const MM = pad2(now.getMinutes());
const SS = pad2(now.getSeconds());
const time = `${HH}:${MM}:${SS}`;
const needYear = yyyy !== module_epoch_d.getFullYear();
const needMonth = needYear || (now.getMonth() !== module_epoch_d.getMonth());
const needDay = needMonth || (now.getDate() !== module_epoch_d.getDate());
if ( needYear ) return `${yyyy}-${mm}-${dd} ${time}`;
if ( needMonth ) return `${mm}-${dd} ${time}`;
if ( needDay ) return `${dd} ${time}`;
return time;
};
let memoized_errors = null;
/**
* This class creates the `extension` global that is seen by Puter backend
* extensions.
*/
class Extension extends AdvancedBase {
static FEATURES = [
EmitterFeature({
decorators: [
fn => Context.get(undefined, {
allow_fallback: true,
}).abind(fn),
],
}),
];
constructor (...a) {
super(...a);
this.service = null;
this.log = null;
this.ensure_service_();
// this.terminal_color = this.randomBrightColor();
this.terminal_color = 94;
this.log = (...a) => {
this.log_context.info(a.join(' '));
};
this.LOG = (...a) => {
this.log_context.noticeme(a.join(' '));
};
['info', 'warn', 'debug', 'error', 'tick', 'noticeme', 'system'].forEach(lvl => {
this.log[lvl] = (...a) => {
this.log_context[lvl](...a);
};
});
this.only_one_preinit_fn = null;
this.only_one_init_fn = null;
this.registry = {
register: this.register.bind(this),
of: (typeKey) => {
return {
named: name => {
if ( arguments.length === 0 ) {
return this.registry_[typeKey].named;
}
return this.registry_[typeKey].named[name];
},
all: () => [
...Object.values(this.registry_[typeKey].named),
...this.registry_[typeKey].anonymous,
],
};
},
};
}
randomBrightColor () {
// Bright colors in ANSI (foreground codes 90–97)
const brightColors = [
// 91, // Bright Red
92, // Bright Green
// 93, // Bright Yellow
94, // Bright Blue
95, // Bright Magenta
// 96, // Bright Cyan
];
return brightColors[Math.floor(Math.random() * brightColors.length)];
}
example () {
console.log('Example method called by an extension.');
}
// === [START] RuntimeModule aliases ===
set exports (value) {
this.runtime.exports = value;
}
get exports () {
return this.runtime.exports;
}
import (name) {
return this.runtime.import(name);
}
// === [END] RuntimeModule aliases ===
/**
* This will get a database instance from the default service.
*/
get db () {
const db = this.service.values.get('db');
if ( ! db ) {
throw new Error('extension tried to access database before it was ' +
'initialized');
}
return db;
}
get services () {
const services = this.service.values.get('services');
if ( ! services ) {
throw new Error('extension tried to access "services" before it was ' +
'initialized');
}
return services;
}
get log_context () {
const log_context = this.service.values.get('log_context');
if ( ! log_context ) {
throw new Error('extension tried to access "log_context" before it was ' +
'initialized');
}
return log_context;
}
get errors () {
return memoized_errors ?? (() => {
return this.services.get('error-service').create(this.log_context);
})();
}
/**
* Register anonymous or named data to a particular type/category.
* @param {string} typeKey Type of data being registered
* @param {string} [key] Key of data being registered
* @param {any} data The data to be registered
*/
register (typeKey, keyOrData, data) {
if ( ! this.registry_[typeKey] ) {
this.registry_[typeKey] = {
named: {},
anonymous: [],
};
}
const typeRegistry = this.registry_[typeKey];
if ( arguments.length <= 1 ) {
throw new Error('you must specify what to register');
}
if ( arguments.length === 2 ) {
data = keyOrData;
if ( Array.isArray(data) ) {
for ( const datum of data ) {
typeRegistry.anonymous.push(datum);
}
return;
}
typeRegistry.anonymous.push(data);
return;
}
const key = keyOrData;
typeRegistry.named[key] = data;
}
/**
* Alias for .register()
* @param {string} typeKey Type of data being registered
* @param {string} [key] Key of data being registered
* @param {any} data The data to be registered
*/
reg (...a) {
this.register(...a);
}
/**
* This will create a GET endpoint on the default service.
* @param {*} path - route for the endpoint
* @param {*} handler - function to handle the endpoint
* @param {*} options - options like noauth (bool) and mw (array)
*/
get (path, handler, options) {
// this extension will have a default service
this.ensure_service_();
// handler and options may be flipped
if ( typeof handler === 'object' ) {
[handler, options] = [options, handler];
}
if ( ! options ) options = {};
this.service.register_route_handler_(path, handler, {
...options,
methods: ['GET'],
});
}
/**
* This will create a POST endpoint on the default service.
* @param {*} path - route for the endpoint
* @param {*} handler - function to handle the endpoint
* @param {*} options - options like noauth (bool) and mw (array)
*/
post (path, handler, options) {
// this extension will have a default service
this.ensure_service_();
// handler and options may be flipped
if ( typeof handler === 'object' ) {
[handler, options] = [options, handler];
}
if ( ! options ) options = {};
this.service.register_route_handler_(path, handler, {
...options,
methods: ['POST'],
});
}
/**
* This will create a DELETE endpoint on the default service.
* @param {*} path - route for the endpoint
* @param {*} handler - function to handle the endpoint
* @param {*} options - options like noauth (bool) and mw (array)
*/
put (path, handler, options) {
// this extension will have a default service
this.ensure_service_();
// handler and options may be flipped
if ( typeof handler === 'object' ) {
[handler, options] = [options, handler];
}
if ( ! options ) options = {};
this.service.register_route_handler_(path, handler, {
...options,
methods: ['PUT'],
});
}
/**
* This will create a DELETE endpoint on the default service.
* @param {*} path - route for the endpoint
* @param {*} handler - function to handle the endpoint
* @param {*} options - options like noauth (bool) and mw (array)
*/
delete (path, handler, options) {
// this extension will have a default service
this.ensure_service_();
// handler and options may be flipped
if ( typeof handler === 'object' ) {
[handler, options] = [options, handler];
}
if ( ! options ) options = {};
this.service.register_route_handler_(path, handler, {
...options,
methods: ['DELETE'],
});
}
use (...args) {
this.ensure_service_();
this.service.expressThings_.push({
type: 'router',
value: args,
});
}
get preinit () {
return (function (callback) {
this.on('preinit', callback);
}).bind(this);
}
set preinit (callback) {
if ( this.only_one_preinit_fn === null ) {
this.on('preinit', (...a) => {
this.only_one_preinit_fn(...a);
});
}
if ( callback === null ) {
this.only_one_preinit_fn = () => {
};
}
this.only_one_preinit_fn = callback;
}
get init () {
return (function (callback) {
this.on('init', callback);
}).bind(this);
}
set init (callback) {
if ( this.only_one_init_fn === null ) {
this.on('init', (...a) => {
this.only_one_init_fn(...a);
});
}
if ( callback === null ) {
this.only_one_init_fn = () => {
};
}
this.only_one_init_fn = callback;
}
get console () {
const extensionConsole = Object.create(console);
const logfn = level => (...a) => {
let svc_log;
try {
svc_log = this.services.get('log-service');
} catch ( _e ) {
// NOOP
}
if ( ! svc_log ) {
const realConsole = globalThis.original_console_object ?? console;
realConsole[(level => {
if ( ['error', 'warn', 'debug'].includes(level) ) return level;
return 'log';
})(level)](`${display_time(new Date())} \x1B[${this.terminal_color};1m(extension/${this.name})\x1B[0m`, ...a);
return;
}
const extensionLogger = svc_log.create(`extension/${this.name}`);
const util = require('node:util');
const consoleStyle = a.map(arg => {
if ( typeof arg === 'string' ) return arg;
return util.inspect(arg, undefined, undefined, true);
}).join(' ');
extensionLogger[level](consoleStyle);
};
extensionConsole.log = logfn('info');
extensionConsole.error = logfn('error');
extensionConsole.warn = logfn('warn');
return extensionConsole;
}
get tracer () {
const trace = this.import('tel').trace;
return trace.getTracer(`extension:${this.name}`);
}
get span () {
const span = (label, fn) => {
const spanify = this.import('core').spanify;
return spanify(label, fn, this.tracer);
};
// Add `.run` for more readable immediate invocation
span.run = (label, fn) => {
if ( typeof label === 'function' ) {
fn = label;
label = fn.name || 'span.run';
}
return span(label, fn)();
};
return span;
}
/**
* This method will create the "default service" for an extension.
* This is specifically for Puter extensions that do not define their
* own service classes.
*
* @returns {void}
*/
ensure_service_ () {
if ( this.service ) {
return;
}
this.service = new ExtensionServiceState({
extension: this,
});
}
}
module.exports = {
Extension,
};
================================================
FILE: src/backend/src/ExtensionModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const uuid = require('uuid');
const { ExtensionService } = require('./ExtensionService');
class ExtensionModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
this.extension.name = this.extension.name ?? context.name;
this.extension.emit('install', { context, services });
if ( this.extension.service ) {
services.registerService(uuid.v4(), ExtensionService, {
state: this.extension.service,
}); // uuid for now
}
}
}
module.exports = {
ExtensionModule,
};
================================================
FILE: src/backend/src/ExtensionService.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const BaseService = require('./services/BaseService');
const { Endpoint } = require('./util/expressutil');
const configurable_auth = require('./middleware/configurable_auth');
const { Context } = require('./util/context');
const { DB_WRITE } = require('./services/database/consts');
const { Actor } = require('./services/auth/Actor');
/**
* State shared with the default service and the `extension` global so that
* methods on `extension` can register routes (and make other changes in the
* future) to the default service.
*/
class ExtensionServiceState extends AdvancedBase {
constructor (...a) {
super(...a);
this.extension = a[0].extension;
this.expressThings_ = [];
// Values shared between the `extension` global and its service
this.values = new Context();
}
register_route_handler_ (path, handler, options = {}) {
// handler and options may be flipped
if ( typeof handler === 'object' ) {
[handler, options] = [options, handler];
}
const mw = options.mw ?? [];
// TODO: option for auth middleware is harcoded here, but eventually
// all exposed middlewares should be registered under the simpele names
// used in this options object (probably; still not 100% decided on that)
if ( ! options.noauth ) {
const auth_conf = typeof options.auth === 'object' ?
options.auth : {};
mw.push(configurable_auth(auth_conf));
}
const endpoint = Endpoint({
methods: options.methods ?? ['GET'],
mw,
route: path,
handler: handler,
...(options.subdomain ? { subdomain: options.subdomain } : {}),
otherOpts: options.otherOpts || {},
});
this.expressThings_.push({ type: 'endpoint', value: endpoint });
}
}
/**
* A service that does absolutely nothing by default, but its behavior can be
* extended by adding route handlers and event listeners. This is used to
* provide a default service for extensions.
*/
class ExtensionService extends BaseService {
_construct () {
this.expressThings_ = [];
}
async _init (args) {
this.state = args.state;
this.state.values.set('services', this.services);
this.state.values.set('log_context', this.services.get('log-service').create(
this.state.extension.name,
));
// Create database access object for extension
const db = this.services.get('database').get(DB_WRITE, 'extension');
this.state.values.set('db', db);
// Propagate all events from Puter's event bus to extensions
const svc_event = this.services.get('event');
svc_event.on_all(async (key, data, meta = {}) => {
meta.from_outside_of_extension = true;
await Context.sub({
extension_name: this.state.extension.name,
}).arun(async () => {
const promises = [
// push event to the extension's event bus
this.state.extension.emit(key, data, meta),
// legacy: older extensions prefix "core." to events from Puter
this.state.extension.emit(`core.${key}`, data, meta),
];
// await this.state.extension.emit(key, data, meta);
await Promise.all(promises);
});
// await Promise.all(promises);
});
// Propagate all events from extension to Puter's event bus
this.state.extension.on_all(async (key, data, meta) => {
if ( meta.from_outside_of_extension ) return;
await svc_event.emit(key, data, meta);
});
this.state.extension.kv = (() => {
const impls = this.services.get_implementors('puter-kvstore');
const impl_kv = impls[0].impl;
return new Proxy(impl_kv, {
get: (target, prop) => {
if ( typeof target[prop] !== 'function' ) {
return target[prop];
}
return (...args) => {
if ( typeof args[0] !== 'object' ) {
// Luckily named parameters don't have positional
// overlaps between the different kv methods, so
// we can just set them all.
args[0] = {
key: args[0],
as: args[0],
value: args[1],
amount: args[2],
timestamp: args[2],
ttl: args[2],
};
}
return Context.sub({
actor: Actor.get_system_actor(),
}).arun(() => target[prop](...args));
};
},
});
})();
this.state.extension.emit('preinit');
}
async '__on_boot.consolidation' () {
const svc_su = this.services.get('su');
await svc_su.sudo(async () => {
await this.state.extension.emit('init', {}, {
from_outside_of_extension: true,
});
});
}
async '__on_boot.activation' () {
const svc_su = this.services.get('su');
await svc_su.sudo(async () => {
await this.state.extension.emit('activate', {}, {
from_outside_of_extension: true,
});
});
}
async '__on_boot.ready' () {
const svc_su = this.services.get('su');
await svc_su.sudo(async () => {
await this.state.extension.emit('ready', {}, {
from_outside_of_extension: true,
});
});
}
'__on_install.routes' (_, { app }) {
for ( const thing of this.state.expressThings_ ) {
if ( thing.type === 'endpoint' ) {
thing.value.attach(app);
continue;
}
if ( thing.type === 'router' ) {
app.use(...thing.value);
continue;
}
}
}
}
module.exports = {
ExtensionService,
ExtensionServiceState,
};
================================================
FILE: src/backend/src/Kernel.js
================================================
/*
* 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 .
*/
const { AdvancedBase, libs } = require('@heyputer/putility');
const { Context } = require('./util/context');
const BaseService = require('./services/BaseService');
const useapi = require('useapi');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
const { Extension } = require('./Extension');
const { ExtensionModule } = require('./ExtensionModule');
const { spawn } = require('node:child_process');
const fs = require('fs');
const path_ = require('path');
const { prependToJSFiles } = require('./kernel/modutil');
const { tmp_provide_services } = require('./helpers');
const uuid = require('uuid');
const readline = require('node:readline/promises');
const { RuntimeModuleRegistry } = require('./extension/RuntimeModuleRegistry');
const { RuntimeModule } = require('./extension/RuntimeModule');
const deep_proto_merge = require('./config/deep_proto_merge');
const url = require('url');
const { quot } = libs.string;
class Kernel extends AdvancedBase {
constructor ({ entry_path } = {}) {
super();
this.modules = [];
this.useapi = useapi();
this.useapi.withuse(() => {
def('Module', AdvancedBase);
def('Service', BaseService);
});
this.entry_path = entry_path;
this.extensionExports = {};
this.extensionInfo = {};
this.registry = {};
this.runtimeModuleRegistry = new RuntimeModuleRegistry();
}
add_module (module) {
this.modules.push(module);
}
_runtime_init (boot_parameters) {
global.cl = console.log;
const { RuntimeEnvironment } = require('./boot/RuntimeEnvironment');
const { BootLogger } = require('./boot/BootLogger');
// Temporary logger for boot process;
// LoggerService will be initialized in app.js
const bootLogger = new BootLogger();
this.bootLogger = bootLogger;
// Determine config and runtime locations
const runtimeEnv = new RuntimeEnvironment({
entry_path: this.entry_path,
logger: bootLogger,
boot_parameters,
});
const environment = runtimeEnv.init();
this.environment = environment;
// polyfills
require('./polyfill/to-string-higher-radix');
}
boot () {
const args = yargs(hideBin(process.argv)).argv;
this._runtime_init({ args });
const config = require('./config');
globalThis.ll = o => o;
globalThis.xtra_log = () => {
};
if ( config.env === 'dev' ) {
globalThis.ll = o => {
console.log(`debug: ${ require('node:util').inspect(o)}`);
return o;
};
globalThis.xtra_log = (...args) => {
// append to file in temp
const fs = require('fs');
const path = require('path');
const log_path = path.join('/tmp/xtra_log.txt');
fs.appendFileSync(log_path, `${args.join(' ') }\n`);
};
}
const { consoleLogManager } = require('./util/consolelog');
consoleLogManager.initialize_proxy_methods();
// === START: Initialize Service Registry ===
const { Container } = require('./services/Container');
const services = new Container({ logger: this.bootLogger });
this.services = services;
const root_context = Context.create({
environment: this.environment,
useapi: this.useapi,
services,
config,
logger: this.bootLogger,
extensionExports: this.extensionExports,
extensionInfo: this.extensionInfo,
registry: this.registry,
args,
'runtime-modules': this.runtimeModuleRegistry,
}, 'app');
globalThis.root_context = root_context;
root_context.arun(async () => {
await this._install_modules();
await this._boot_services();
});
Error.stackTraceLimit = 20;
}
async _install_modules () {
const { services } = this;
// Internal modules
for ( const module_ of this.modules ) {
services.registerModule(module_.constructor.name, module_);
const mod_context = this._create_mod_context(Context.get(), {
name: module_.constructor.name,
'module': module_,
external: false,
});
await module_.install(mod_context);
}
for ( const k in services.instances_ ) {
const service_exports = new RuntimeModule({ name: `service:${k}` });
this.runtimeModuleRegistry.register(service_exports);
service_exports.exports = services.instances_[k];
}
// External modules
await this.install_extern_mods_();
try {
await services.init();
} catch (e) {
// First we'll try to mark the system as invalid via
// SystemValidationService. This might fail because this service
// may not be initialized yet.
const svc_systemValidation = (() => {
try {
return services.get('system-validation');
} catch (e) {
return null;
}
})();
if ( ! svc_systemValidation ) {
// If we can't mark the system as invalid, we'll just have to
// throw the error and let the server crash.
throw e;
}
await svc_systemValidation.mark_invalid(
'failed to initialize services',
e,
);
}
for ( const module of this.modules ) {
await module.install_legacy?.(Context.get());
}
services.ready.resolve();
// provide services to helpers
tmp_provide_services(services);
}
async _boot_services () {
const { services } = this;
await services.ready;
await services.emit('boot.consolidation');
// === END: Initialize Service Registry ===
// self check
(async () => {
await services.ready;
globalThis.services = services;
const log = services.get('log-service').create('init');
log.system('server ready', {
deployment_type: globalThis.deployment_type,
});
})();
await services.emit('boot.activation');
await services.emit('boot.ready');
// Notify process managers (e.g., PM2 wait_ready) that boot completed
if ( typeof process.send === 'function' ) {
try {
process.send('ready');
} catch ( err ) {
this.bootLogger?.error?.('failed to send ready signal', err);
}
}
}
async install_extern_mods_ () {
// In runtime directory, we'll create a `mod_packages` directory.`
if ( fs.existsSync('mod_packages') ) {
fs.rmSync('mod_packages', { recursive: true, force: true });
}
fs.mkdirSync('mod_packages');
// Initialize some globals that external mods depend on
globalThis.__puter_extension_globals__ = {
extensionObjectRegistry: {},
useapi: this.useapi,
global_config: require('./config'),
};
// Also expose global_config globally
globalThis.global_config = require('./config');
// Install the mods...
const mod_install_root_context = Context.get();
const mod_directory_promises = [];
const mod_installation_promises = [];
const mod_paths = this.environment.mod_paths;
for ( const mods_dirpath of mod_paths ) {
const p = (async () => {
if ( ! fs.existsSync(mods_dirpath) ) {
this.services.logger.error(`mod directory not found: ${quot(mods_dirpath)}; skipping...`);
// intentional delay so error is seen
this.services.logger.info('boot will continue in 4 seconds');
await new Promise(rslv => setTimeout(rslv, 4000));
return;
}
const mod_dirnames = await fs.promises.readdir(mods_dirpath);
const ignoreList = new Set([
'.git',
]);
for ( const mod_dirname of mod_dirnames ) {
if ( ignoreList.has(mod_dirname) ) continue;
mod_installation_promises.push(this.install_extern_mod_({
mod_install_root_context,
mod_dirname,
mod_path: path_.join(mods_dirpath, mod_dirname),
}));
}
})();
if ( process.env.SYNC_MOD_INSTALL ) await p;
mod_directory_promises.push(p);
}
await Promise.all(mod_directory_promises);
const mods_to_run = (await Promise.all(mod_installation_promises))
.filter(v => v !== undefined);
mods_to_run.sort((a, b) => a.priority - b.priority);
let i = 0;
while ( i < mods_to_run.length ) {
const currentPriority = mods_to_run[i].priority;
const samePriorityMods = [];
// Collect all mods with the same priority
while ( i < mods_to_run.length && mods_to_run[i].priority === currentPriority ) {
samePriorityMods.push(mods_to_run[i]);
i++;
}
// Run all mods with the same priority concurrently
await Promise.all(samePriorityMods.map(mod_entry => {
return this._run_extern_mod(mod_entry);
}));
}
}
async install_extern_mod_ ({
mod_install_root_context,
mod_dirname,
mod_path,
}) {
let stat = fs.lstatSync(mod_path);
while ( stat.isSymbolicLink() ) {
mod_path = fs.readlinkSync(mod_path);
stat = fs.lstatSync(mod_path);
}
// Mod must be a directory or javascript file
if ( !stat.isDirectory() && !(mod_path.endsWith('.js')) ) {
return;
}
let mod_name = path_.parse(mod_path).name;
const mod_package_dir = `mod_packages/${mod_name}`;
fs.mkdirSync(mod_package_dir);
const mod_entry = {
priority: 0,
jsons: {},
};
if ( ! stat.isDirectory() ) {
const rl = readline.createInterface({
input: fs.createReadStream(mod_path),
});
for await ( const line of rl ) {
if ( line.trim() === '' ) continue;
if ( ! line.startsWith('//@extension') ) break;
const tokens = line.split(' ');
if ( tokens[1] === 'priority' ) {
mod_entry.priority = Number(tokens[2]);
}
if ( tokens[1] === 'name' ) {
mod_name = `${ tokens[2]}`;
}
}
mod_entry.jsons.package = await this.create_mod_package_json(mod_package_dir, {
name: mod_name,
entry: 'main.js',
});
await fs.promises.copyFile(mod_path, path_.join(mod_package_dir, 'main.js'));
} else {
// If directory is empty, we'll just skip it
if ( fs.readdirSync(mod_path).length === 0 ) {
this.bootLogger.warn(`Empty mod directory ${quot(mod_path)}; skipping...`);
return;
}
const promises = [];
// Create package.json if it doesn't exist
promises.push((async () => {
if ( ! fs.existsSync(path_.join(mod_path, 'package.json')) ) {
mod_entry.jsons.package = await this.create_mod_package_json(mod_package_dir, {
name: mod_name,
});
} else {
const bin = await fs.promises.readFile(path_.join(mod_path, 'package.json'));
const str = bin.toString();
mod_entry.jsons.package = JSON.parse(str);
}
})());
const puter_json_path = path_.join(mod_path, 'puter.json');
if ( fs.existsSync(puter_json_path) ) {
promises.push((async () => {
const buffer = await fs.promises.readFile(puter_json_path);
const json = buffer.toString();
const obj = JSON.parse(json);
mod_entry.priority = obj.priority ?? mod_entry.priority;
mod_entry.jsons.puter = obj;
})());
}
const config_json_path = path_.join(mod_path, 'config.json');
if ( fs.existsSync(config_json_path) ) {
promises.push((async () => {
const buffer = await fs.promises.readFile(config_json_path);
const json = buffer.toString();
const obj = JSON.parse(json);
mod_entry.priority = obj.priority ?? mod_entry.priority;
mod_entry.jsons.config = obj;
})());
}
// Copy mod contents to `/mod_packages`
promises.push(fs.promises.cp(mod_path, mod_package_dir, {
recursive: true,
}));
await Promise.all(promises);
}
mod_entry.priority = mod_entry.jsons.puter?.priority ?? mod_entry.priority;
const extension_id = uuid.v4();
await prependToJSFiles(mod_package_dir, `${[
'const { use, def } = globalThis.__puter_extension_globals__.useapi;',
'const { use: puter } = globalThis.__puter_extension_globals__.useapi;',
'const extension = globalThis.__puter_extension_globals__' +
`.extensionObjectRegistry[${JSON.stringify(extension_id)}];`,
'const console = extension.console;',
'const runtime = extension.runtime;',
'const config = extension.config;',
'const registry = extension.registry;',
'const register = registry.register;',
'const global_config = globalThis.__puter_extension_globals__.global_config',
].join('\n') }\n`);
mod_entry.require_dir = path_.join(process.cwd(), mod_package_dir);
await this.run_npm_install(mod_entry.require_dir);
const mod = new ExtensionModule();
mod.extension = new Extension();
const runtimeModule = new RuntimeModule({ name: mod_name });
this.runtimeModuleRegistry.register(runtimeModule);
mod.extension.runtime = runtimeModule;
mod_entry.module = mod;
globalThis.__puter_extension_globals__.extensionObjectRegistry[extension_id]
= mod.extension;
const mod_context = this._create_mod_context(mod_install_root_context, {
name: mod_name,
'module': mod,
external: true,
mod_path,
});
mod_entry.context = mod_context;
return mod_entry;
};
async _run_extern_mod (mod_entry) {
let exportObject = null;
let {
module: mod,
require_dir,
context,
} = mod_entry;
const packageJSON = mod_entry.jsons.package;
Object.defineProperty(mod.extension, 'config', {
get: () => {
const builtin_config = mod_entry.jsons.config ?? {};
const user_config = require('./config').extensions?.[packageJSON.name] ?? {};
return deep_proto_merge(user_config, builtin_config);
},
});
mod.extension.name = packageJSON.name;
// Platform normalization for if import is used in the place of require();
let importPath = path_.join(require_dir, packageJSON.main ?? 'index.js');
if ( process.platform === 'win32' ) {
importPath = (url.pathToFileURL(importPath)).href;
}
const maybe_promise = (typ => typ.trim().toLowerCase())(packageJSON.type ?? '') === 'module'
? await import(importPath)
: require(require_dir);
if ( maybe_promise && maybe_promise instanceof Promise ) {
exportObject = await maybe_promise;
} else exportObject = maybe_promise;
const extension_name = exportObject?.name ?? packageJSON.name;
this.extensionExports[extension_name] = exportObject;
this.extensionInfo[extension_name] = {
name: extension_name,
priority: mod_entry.priority,
type: packageJSON?.type ?? 'commonjs',
};
mod.extension.registry = this.registry;
mod.extension.name = extension_name;
if ( exportObject.construct ) {
mod.extension.on('construct', exportObject.construct);
}
if ( exportObject.preinit ) {
mod.extension.on('preinit', exportObject.preinit);
}
if ( exportObject.init ) {
mod.extension.on('init', exportObject.init);
}
// This is where the 'install' event gets triggered
await mod.install(context);
}
_create_mod_context (parent, options) {
const modapi = {};
let mod_path = options.mod_path;
if ( !mod_path && options.module.dirname ) {
mod_path = options.module.dirname();
}
if ( mod_path ) {
modapi.libdir = (prefix, directory) => {
const fullpath = path_.join(mod_path, directory);
const fsitems = fs.readdirSync(fullpath);
for ( const item of fsitems ) {
if ( !item.endsWith('.js') && !item.endsWith('.cjs') && !item.endsWith('.mjs') ) {
continue;
}
if ( item.endsWith('.test.js') || item.endsWith('.bench.js') ) {
continue;
}
const stat = fs.statSync(path_.join(fullpath, item));
if ( ! stat.isFile() ) {
continue;
}
const name = item.slice(0, -3);
const path = path_.join(fullpath, item);
let lib = require(path);
// TODO: This context can be made dynamic by adding a
// getter-like behavior to useapi.
this.useapi.def(`${prefix}.${name}`, lib);
}
};
}
const mod_context = parent.sub({ modapi }, `mod:${options.name}`);
return mod_context;
}
async create_mod_package_json (mod_path, { name, entry }) {
// Expect main.js or index.js to exist
const options = ['main.js', 'index.js'];
// If no entry specified, find file with conventional name
if ( ! entry ) {
for ( const option of options ) {
if ( fs.existsSync(path_.join(mod_path, option)) ) {
entry = option;
break;
}
}
}
// If no entry specified or found, skip or error
if ( ! entry ) {
this.bootLogger.error(`Expected main.js or index.js in ${quot(mod_path)}`);
if ( ! process.env.SKIP_INVALID_MODS ) {
this.bootLogger.error('Set SKIP_INVALID_MODS=1 (environment variable) to run anyway.');
process.exit(1);
} else {
return;
}
}
const data = {
name,
version: '1.0.0',
main: entry ?? 'main.js',
};
const data_json = JSON.stringify(data);
this.bootLogger.debug(`WRITING TO: ${ path_.join(mod_path, 'package.json')}`);
await fs.promises.writeFile(path_.join(mod_path, 'package.json'), data_json);
return data;
}
async run_npm_install (path) {
const npmOptions = process.platform === 'win32'
? ['npm.cmd', ['install'], { shell: true, cwd: path, stdio: 'pipe' }]
: ['npm', ['install'], { cwd: path, stdio: 'pipe' }];
const proc = spawn(...npmOptions);
let buffer = '';
proc.stdout.on('data', (data) => {
buffer += data.toString();
});
proc.stderr.on('data', (data) => {
buffer += data.toString();
});
return new Promise((rslv, rjct) => {
proc.on('close', code => {
if ( code !== 0 ) {
// Print buffered output on error
if ( buffer ) process.stdout.write(buffer);
rjct(new Error(`exit code: ${code}`));
return;
}
rslv();
});
proc.on('error', err => {
// Print buffered output on error
if ( buffer ) process.stdout.write(buffer);
rjct(err);
});
});
}
}
module.exports = { Kernel };
================================================
FILE: src/backend/src/LocalDiskStorageModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
class LocalDiskStorageModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const LocalDiskStorageService = require('./services/LocalDiskStorageService');
services.registerService('local-disk-storage', LocalDiskStorageService);
const HostDiskUsageService = require('./services/HostDiskUsageService');
services.registerService('host-disk-usage', HostDiskUsageService);
}
}
module.exports = LocalDiskStorageModule;
================================================
FILE: src/backend/src/MemoryStorageModule.js
================================================
/*
* 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 .
*/
class MemoryStorageModule {
async install (context) {
const services = context.get('services');
const MemoryStorageService = require('./services/MemoryStorageService');
services.registerService('memory-storage', MemoryStorageService);
}
}
module.exports = MemoryStorageModule;
================================================
FILE: src/backend/src/annotatedobjects.js
================================================
// This sucks, but the concept is simple...
// When debugging memory leaks, sometimes plain objects (rather than instances
// of classes) are the culprit. However, theses are very difficult to identify
// in heap snapshots using the Memory tab in Chromium dev tools.
// These annotated classes provide a solution to wrap plain objects.
class AnnotatedObject {
constructor (o) {
for ( const k in o ) this[k] = o[k];
}
}
class object_returned_by_get_app extends AnnotatedObject {
};
module.exports = {
object_returned_by_get_app,
};
================================================
FILE: src/backend/src/api/APIError.js
================================================
/*
* 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 .
*/
const { URLSearchParams } = require('node:url');
const { quot } = require('@heyputer/putility').libs.string;
/**
* APIError represents an error that can be sent to the client.
* @class APIError
* @property {number} status the HTTP status code
* @property {string} message the error message
* @property {object} source the source of the error
*/
class APIError {
static codes = {
// General
'unknown_error': {
status: 500,
message: () => 'An unknown error occurred',
},
'format_error': {
status: 400,
message: ({ message }) => `format error: ${message}`,
},
'temp_error': {
status: 400,
message: ({ message }) => `error: ${message}`,
},
'disallowed_value': {
status: 400,
message: ({ key, allowed }) =>
`value of ${quot(key)} must be one of: ${
allowed.map(v => quot(v)).join(', ')}`,
},
'invalid_token': {
status: 400,
message: () => 'Invalid token',
},
'unrecognized_offering': {
status: 400,
message: ({ name }) => {
return `offering ${quot(name)} was not recognized.`;
},
},
'error_400_from_delegate': {
status: 400,
message: ({ delegate, message }) => `Error 400 from delegate ${quot(delegate)}: ${message}`,
},
// Things
'disallowed_thing': {
status: 400,
message: ({ thing_type, accepted }) =>
`Request contained a ${quot(thing_type)} in a ` +
`place where ${quot(thing_type)} isn't accepted${
accepted
? '; ' +
`accepted types are: ${
accepted.map(v => quot(v)).join(', ')}`
: ''}.`,
},
// Unorganized
'item_with_same_name_exists': {
status: 409,
message: ({ entry_name }) => entry_name
? `An item with name ${quot(entry_name)} already exists.`
: 'An item with the same name already exists.'
,
},
'cannot_move_item_into_itself': {
status: 422,
message: 'Cannot move an item into itself.',
},
'cannot_copy_item_into_itself': {
status: 422,
message: 'Cannot copy an item into itself.',
},
'directory_depth_limit_exceeded': {
status: 422,
message: ({ limit, would_be }) => `Directory depth limit exceeded. Limit is ${limit}, would be ${would_be}.`,
},
'cannot_move_to_root': {
status: 422,
message: 'Cannot move an item to the root directory.',
},
'cannot_copy_to_root': {
status: 422,
message: 'Cannot copy an item to the root directory.',
},
'cannot_write_to_root': {
status: 422,
message: 'Cannot write an item to the root directory.',
},
'cannot_overwrite_a_directory': {
status: 422,
message: 'Cannot overwrite a directory.',
},
'cannot_read_a_directory': {
status: 422,
message: 'Cannot read a directory.',
},
'source_and_dest_are_the_same': {
status: 422,
message: 'Source and destination are the same.',
},
'dest_is_not_a_directory': {
status: 422,
message: 'Destination must be a directory.',
},
'dest_does_not_exist': {
status: 422,
message: ({ what_dest }) => {
if ( ! what_dest ) {
return 'Destination was not found.';
}
return `Destination of ${quot(what_dest)} was not found.`;
},
},
'source_does_not_exist': {
status: 404,
message: 'Source was not found.',
},
'subject_does_not_exist': {
status: 404,
message: 'File or directory not found.',
},
'shortcut_target_not_found': {
status: 404,
message: 'Shortcut target not found.',
},
'shortcut_target_is_a_directory': {
status: 422,
message: 'Shortcut target is a directory; expected a file.',
},
'shortcut_target_is_a_file': {
status: 422,
message: 'Shortcut target is a file; expected a directory.',
},
'forbidden': {
status: 403,
message: ({ debug_reason }) => (process.env.DEBUG && debug_reason)
? `Permission denied: ${debug_reason}`
: 'Permission denied.',
},
'immutable': {
status: 403,
message: 'File is immutable.',
},
'field_empty': {
status: 400,
message: ({ key }) => `Field ${quot(key)} is required.`,
},
'too_many_keys': {
status: 400,
message: ({ key }) => `Field ${quot(key)} cannot contain more than 100 elements.`,
},
'field_missing': {
status: 400,
message: ({ key }) => `Field ${quot(key)} is required.`,
},
'fields_missing': {
status: 400,
message: ({ keys }) => `The following fields are required but missing: ${keys.map(quot).join(', ')}.`,
},
'xor_field_missing': {
status: 400,
message: ({ names }) => {
let s = 'One of these mutually-exclusive fields is required: ';
s += names.map(quot).join(', ');
return s;
},
},
'field_only_valid_with_other_field': {
status: 400,
message: ({ key, other_key }) => `Field ${quot(key)} is only valid when field ${quot(other_key)} is specified.`,
},
'invalid_id': {
status: 400,
message: ({ id }) => {
return `Invalid id ${id}`;
},
},
'invalid_operation': {
status: 400,
message: ({ operation }) => `Invalid operation: ${quot(operation)}.`,
},
'field_invalid': {
status: 400,
message: ({ key, expected, got }) => {
return `Field ${quot(key)} is invalid.${
expected ? ` Expected ${expected}.` : ''
}${got ? ` Got ${got}.` : ''}`;
},
},
'fields_invalid': {
status: 400,
message: ({ errors }) => {
let s = 'The following validation errors occurred: ';
s += errors.map(error => `Field ${quot(error.key)} is invalid.${
error.expected ? ` Expected ${error.expected}.` : ''
}${error.got ? ` Got ${error.got}.` : ''}`).join(', ');
return s;
},
},
'field_immutable': {
status: 400,
message: ({ key }) => `Field ${quot(key)} is immutable.`,
},
'field_too_long': {
status: 400,
message: ({ key, max_length }) => `Field ${quot(key)} is too long. Max length is ${max_length}.`,
},
'field_too_short': {
status: 400,
message: ({ key, min_length }) => `Field ${quot(key)} is too short. Min length is ${min_length}.`,
},
'already_in_use': {
status: 409,
message: ({ what, value }) => `The ${what} ${quot(value)} is already in use.`,
},
'invalid_file_name': {
status: 400,
message: ({ name, reason }) => `Invalid file name: ${quot(name)}${reason ? `; ${reason}` : '.'}`,
},
'storage_limit_reached': {
status: 400,
message: 'Storage capacity limit reached.',
},
'internal_error': {
status: 500,
message: ({ message }) => message
? `An internal error occurred: ${quot(message)}`
: 'An internal error occurred.',
},
'response_timeout': {
status: 504,
message: 'Response timed out.',
},
'file_too_large': {
status: 413,
message: ({ max_size }) => `File too large. Max size is ${max_size} bytes.`,
},
'thumbnail_too_large': {
status: 413,
message: ({ max_size }) => `Thumbnail too large. Max size is ${max_size} bytes.`,
},
'upload_failed': {
status: 500,
message: 'Upload failed.',
},
'missing_expected_metadata': {
status: 400,
message: ({ keys }) => `These fields must come first: ${(keys ?? []).map(quot).join(', ')}.`,
},
'overwrite_and_dedupe_exclusive': {
status: 400,
message: 'Cannot specify both overwrite and dedupe_name.',
},
'not_empty': {
status: 422,
message: 'Directory is not empty.',
},
'readdir_of_non_directory': {
status: 422,
message: 'Readdir target must be a directory.',
},
// Write
'offset_without_existing_file': {
status: 404,
message: 'An offset was specified, but the file doesn\'t exist.',
},
'offset_requires_overwrite': {
status: 400,
message: 'An offset was specified, but overwrite conditions were not met.',
},
'offset_requires_stream': {
status: 400,
message: 'The offset option for write is not available for this upload.',
},
// Batch
'batch_too_many_files': {
status: 400,
message: 'Received an extra file with no corresponding operation.',
},
'batch_missing_file': {
status: 400,
message: 'Missing fileinfo entry or BLOB for operation.',
},
'invalid_file_metadata': {
status: 400,
message: 'Invalid file metadata.',
},
'unresolved_relative_path': {
status: 400,
message: ({ path }) => `Unresolved relative path: ${quot(path)}. ` +
"You may need to specify a full path starting with '/'.",
},
'missing_filesystem_capability': {
status: 422,
message: ({ action, subjectName, providerName, capability }) => {
return `Cannot perform action ${quot(action)} on ` +
`${quot(subjectName)} because it is inside a filesystem ` +
`of type ${providerName}, which does not implement the ` +
`required capability called ${quot(capability)}.`;
},
},
// Open
'no_suitable_app': {
status: 422,
message: ({ entry_name }) => `No suitable app found for ${quot(entry_name)}.`,
},
'app_does_not_exist': {
status: 422,
message: ({ identifier }) => `App ${quot(identifier)} does not exist.`,
},
// Apps
'app_name_already_in_use': {
status: 409,
message: ({ name }) => `App name ${quot(name)} is already in use.`,
},
'app_index_url_already_in_use': {
status: 409,
message: ({ index_url: indexUrl, app_uid: appUid }) =>
`Index URL ${quot(indexUrl)} is already used by app ${quot(appUid)}.`,
},
// Subdomains
'subdomain_limit_reached': {
status: 400,
message: ({ limit, isWorker }) => isWorker ? `You have exceeded the maximum number of workers for your plan! (${limit})` : `You have exceeded the number of subdomains under your current plan (${limit}).`,
},
'subdomain_reserved': {
status: 400,
message: ({ subdomain }) => `Subdomain ${quot(subdomain)} is not available.`,
},
'subdomain_not_owned': {
status: 403,
message: ({ subdomain }) => `You must own the ${quot(subdomain)} subdomain on Puter to use it for this app.`,
},
// Users
'email_already_in_use': {
status: 409,
message: ({ email }) => `Email ${quot(email)} is already in use.`,
},
'email_not_allowed': {
status: 400,
message: ({ email }) => `The email ${quot(email)} is not allowed.`,
},
'username_already_in_use': {
status: 409,
message: ({ username }) => `Username ${quot(username)} is already in use.`,
},
'too_many_username_changes': {
status: 429,
message: 'Too many username changes this month.',
},
'token_invalid': {
status: 400,
message: () => 'Invalid token.',
},
// SLA
'rate_limit_exceeded': {
status: 429,
message: ({ method_name, rate_limit }) =>
`Rate limit exceeded for method ${quot(method_name)}: ${rate_limit.max} requests per ${rate_limit.period}ms.`,
},
'server_rate_exceeded': {
status: 503,
message: 'System-wide rate limit exceeded. Please try again later.',
},
// New cost system
'insufficient_funds': {
status: 402,
message: 'Available funding is insufficient for this request.',
},
// auth
'token_missing': {
status: 401,
message: 'Missing authentication token.',
},
'unexpected_undefined': {
status: 401,
message: msg => msg ?? 'unexpected string undefined',
},
'token_auth_failed': {
status: 401,
message: 'Authentication failed.',
},
'user_not_found': {
status: 401,
message: 'User not found.',
},
'token_unsupported': {
status: 401,
message: 'This authentication token is not supported here.',
},
'token_expired': {
status: 401,
message: 'Authentication token has expired.',
},
'account_suspended': {
status: 403,
message: 'Account suspended.',
},
'permission_denied': {
status: 403,
message: 'Permission denied.',
},
'access_token_empty_permissions': {
status: 403,
message: 'Attempted to create an access token with no permissions.',
},
'invalid_action': {
status: 400,
message: ({ action }) => `Invalid action: ${quot(action)}.`,
},
'2fa_already_enabled': {
status: 409,
message: '2FA is already enabled.',
},
'2fa_not_configured': {
status: 409,
message: '2FA is not configured.',
},
// protected endpoints
'too_many_requests': {
status: 429,
message: 'Too many requests.',
},
'user_tokens_only': {
status: 403,
message: 'This endpoint must be requested with a user session',
},
'session_required': {
status: 403,
message: 'This endpoint requires a full session (e.g. change password cannot be done with a GUI token).',
},
'temporary_accounts_not_allowed': {
status: 403,
message: 'Temporary accounts cannot perform this action',
},
'password_required': {
status: 400,
message: 'Password is required.',
},
'password_mismatch': {
status: 403,
message: 'Password does not match.',
},
'oidc_revalidation_required': {
status: 403,
message: 'Re-validate by signing in with your linked account (e.g. Google).',
},
// Object Mapping
'field_not_allowed_for_create': {
status: 400,
message: ({ key }) => `Field ${quot(key)} is not allowed for create.`,
},
'field_required_for_update': {
status: 400,
message: ({ key }) => `Field ${quot(key)} is required for update.`,
},
'entity_not_found': {
status: 422,
message: ({ identifier }) => `Entity not found: ${quot(identifier)}`,
},
// Share
'user_does_not_exist': {
status: 422,
message: ({ username }) => `The user ${quot(username)} does not exist.`,
},
'invalid_username_or_email': {
status: 400,
message: ({ value }) =>
`The value ${quot(value)} is not a valid username or email.`,
},
'invalid_path': {
status: 400,
message: ({ value }) =>
`The value ${quot(value)} is not a valid path.`,
},
'future': {
status: 400,
message: ({ what }) => `Not supported yet: ${what}`,
},
// Temporary solution for lack of error composition
'field_errors': {
status: 400,
message: ({ key, errors }) =>
`The value for ${quot(key)} has the following errors: ${
errors.join('; ')}`,
},
'share_expired': {
status: 422,
message: 'This share is expired.',
},
'email_must_be_confirmed': {
status: 422,
message: ({ action }) =>
`Email must be confirmed to ${action ?? 'apply a share'}. Go to https://puter.com to confirm your email address.`,
},
'no_need_to_request': {
status: 422,
message: 'This share is already valid for this user; ' +
'POST to /apply for access.',
},
'can_not_apply_to_this_user': {
status: 422,
message: 'This share can not be applied to this user.',
},
'no_origin_for_app': {
status: 400,
message: 'Puter apps must have a valid URL.',
},
'anti-csrf-incorrect': {
status: 400,
message: 'Incorrect or missing anti-CSRF token.',
},
'not_yet_supported': {
status: 400,
message: ({ message }) => message,
},
// Captcha errors
'captcha_required': {
status: 400,
message: ({ message }) => message || 'Captcha verification required',
},
'captcha_invalid': {
status: 400,
message: ({ message }) => message || 'Invalid captcha response',
},
// TTS Errors
'invalid_engine': {
status: 400,
message: ({ engine, valid_engines }) => `Invalid engine: ${quot(engine)}. Valid engines are: ${valid_engines.map(quot).join(', ')}.`,
},
// Abuse prevention
'moderation_failed': {
status: 422,
message: 'Content moderation failed',
},
// Requests
'ip_not_allowed': {
status: 422,
message: () => 'Specifying host by IP address is not allowed here.',
},
};
/**
* create() is a factory method for creating APIError instances.
* It accepts either a string or an Error object as the second
* argument. If a string is passed, it is used as the error message.
* If an Error object is passed, its message property is used as the
* error message. The Error object itself is stored in the source
* property. If no second argument is passed, the source property
* is set to null. The first argument is used as the status code.
*
* @static
* @param {number|string} status
* @param {Error | null} source
* @param {string|Error|object} fields one of the following:
* - a string to use as the error message
* - an Error object to use as the source of the error
* - an object with a message property to use as the error message
* @returns
*/
static create (status, source = {}, fields = {}) {
// Just the error code
if ( typeof status === 'string' ) {
const code = this.codes[status];
if ( ! code ) {
return new APIError(500, 'Missing error message.', null, {
code: status,
});
}
return new APIError(code.status, status, source, fields);
}
// High-level errors like this: APIError.create(400, '...')
if ( typeof source === 'string' ) {
return new APIError(status, source, null, fields);
}
// Errors from source like this: throw new Error('...')
if (
typeof source === 'object' &&
source instanceof Error
) {
return new APIError(status, source?.message, source, fields);
}
// Errors from sources like this: throw { message: '...', ... }
if (
typeof source === 'object' &&
source.constructor.name === 'Object' &&
Object.prototype.hasOwnProperty.call(source, 'message')
) {
const allfields = { ...source, ...fields };
return new APIError(status, source.message, source, allfields);
}
console.error('Invalid APIError source:', source);
return new APIError(500, 'Internal Server Error', null, {});
}
static adapt (err) {
if ( err instanceof APIError ) return err;
return APIError.create('internal_error');
}
constructor (status, message, source, fields = {}) {
this.codes = this.constructor.codes;
this.status = status;
this._message = message;
this.source = source ?? new Error('error for trace');
this.fields = fields;
if ( Object.prototype.hasOwnProperty.call(this.codes, message) ) {
this.fields.code = message;
this._message = this.codes[message].message;
}
}
write (res) {
const message = typeof this.message === 'function'
? this.message(this.fields)
: this.message;
return res.status(this.status).send({
message,
...this.fields,
});
}
serialize () {
return {
...this.fields,
$: 'heyputer:api/APIError',
message: this.message,
status: this.status,
};
}
querystringize (extra) {
return new URLSearchParams(this.querystringize_(extra));
}
querystringize_ (extra) {
const fields = {};
for ( const k in this.fields ) {
fields[`field_${k}`] = this.fields[k];
}
return {
...extra,
error: true,
message: this.message,
status: this.status,
...fields,
};
}
get message () {
const message = typeof this._message === 'function'
? this._message(this.fields)
: this._message;
return message;
}
toString () {
return `APIError(${this.status}, ${this.message})`;
}
};
module.exports = APIError;
module.exports.APIError = APIError;
================================================
FILE: src/backend/src/api/PathOrUIDValidator.js
================================================
/*
* 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 .
*/
const APIError = require('./APIError');
const _path = require('path');
/**
* PathOrUIDValidator validates that either `path` or `uid` is present
* in the request and requires a valid value for the parameter that was
* used. Additionally, resolves the path if a path was provided.
*
* @class PathOrUIDValidator
* @static
* @throws {APIError} if `path` and `uid` are both missing
* @throws {APIError} if `path` and `uid` are both present
* @throws {APIError} if `path` is not a string
* @throws {APIError} if `path` is empty
* @throws {APIError} if `uid` is not a valid uuid
*/
module.exports = class PathOrUIDValidator {
static validate (req) {
const params = req.method === 'GET'
? req.query : req.body ;
if ( !params.path && !params.uid )
{
throw new APIError(400, '`path` or `uid` must be provided.');
}
// `path` must be a string
else if ( params.path && !params.uid && typeof params.path !== 'string' )
{
throw new APIError(400, '`path` must be a string.');
}
// `path` cannot be empty
else if ( params.path && !params.uid && params.path.trim() === '' )
{
throw new APIError(400, '`path` cannot be empty');
}
// `uid` must be a valid uuid
else if ( params.uid && !params.path && !require('uuid').validate(params.uid) )
{
throw new APIError(400, '`uid` must be a valid uuid');
}
// resolve path if provided
if ( params.path )
{
params.path = _path.resolve('/', params.path);
}
}
};
================================================
FILE: src/backend/src/api/api_error_handler.js
================================================
/*
* 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 .
*/
const APIError = require('./APIError');
/**
* api_error_handler() is an express error handler for API errors.
* It adheres to the express error handler signature and should be
* used as the last middleware in an express app.
*
* Since Express 5 is not yet released, this function is used by
* eggspress() to handle errors instead of as a middleware.
*
* @todo remove this function and use express error handling
* when Express 5 is released
*
* @param {*} err
* @param {*} req
* @param {*} res
* @param {*} next
* @returns
*/
module.exports = function (err, req, res, next) {
if ( res.headersSent ) {
console.error('error after headers were sent:', err);
return next(err);
}
// API errors might have a response to help the
// developer resolve the issue.
if ( err instanceof APIError ) {
return err.write(res);
}
if (
typeof err === 'object' &&
!(err instanceof Error) &&
err.hasOwnProperty('message')
) {
const apiError = APIError.create(400, err);
return apiError.write(res);
}
console.error('internal server error:', err);
const services = globalThis.services;
if ( services && services.has('alarm') ) {
const alarm = services.get('alarm');
alarm.create('api_error_handler', err.message, {
error: err,
url: req.url,
method: req.method,
body: req.body,
headers: req.headers,
});
}
req.__error_handled = true;
// Other errors should provide as little information
// to the client as possible for security reasons.
return res.send(500, 'Internal Server Error');
};
================================================
FILE: src/backend/src/api/eggspress.js
================================================
/*
* 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 .
*/
// This file is a legacy alias
module.exports = require('../modules/web/lib/eggspress.js');
================================================
FILE: src/backend/src/api/filesystem/FSNodeParam.js
================================================
/*
* 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 .
*/
const { is_valid_path } = require('../../filesystem/validation');
const { is_valid_uuid4 } = require('../../helpers');
const { Context } = require('../../util/context');
const { PathBuilder } = require('../../util/pathutil');
const APIError = require('../APIError');
class FSNodeParam {
constructor (srckey, options) {
this.srckey = srckey;
this.options = options ?? {};
this.optional = this.options.optional ?? false;
}
async consolidate ({ req, getParam }) {
const log = globalThis.services.get('log-service').create('fsnode-param');
const fs = Context.get('services').get('filesystem');
let uidOrPath = getParam(this.srckey);
if ( uidOrPath === undefined ) {
if ( this.optional ) return undefined;
throw APIError.create('field_missing', null, {
key: this.srckey,
});
}
if ( uidOrPath.length === 0 ) {
if ( this.optional ) return undefined;
APIError.create('field_empty', null, {
key: this.srckey,
});
}
if ( ! ['/', '.', '~'].includes(uidOrPath[0]) ) {
if ( is_valid_uuid4(uidOrPath) ) {
return await fs.node({ uid: uidOrPath });
}
log.debug('tried uuid', { uidOrPath });
throw APIError.create('field_invalid', null, {
key: this.srckey,
expected: 'unix-style path or uuid4',
});
}
if ( uidOrPath.startsWith('~') && req.user ) {
const homedir = `/${req.user.username}`;
uidOrPath = homedir + uidOrPath.slice(1);
}
if ( ! is_valid_path(uidOrPath) ) {
log.debug('tried path', { uidOrPath });
throw APIError.create('field_invalid', null, {
key: this.srckey,
expected: 'unix-style path or uuid4',
});
}
const resolved_path = PathBuilder.resolve(uidOrPath, { puterfs: true });
return await fs.node({ path: resolved_path });
}
};
module.exports = FSNodeParam;
module.exports.FSNodeParam = FSNodeParam;
================================================
FILE: src/backend/src/api/filesystem/FlagParam.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
module.exports = class FlagParam {
constructor (srckey, options) {
this.srckey = srckey;
this.options = options ?? {};
this.optional = this.options.optional ?? false;
this.default = this.options.default ?? false;
}
async consolidate ({ req, getParam }) {
const log = globalThis.services.get('log-service').create('flag-param');
const value = getParam(this.srckey);
if ( value === undefined || value === '' ) {
if ( this.optional ) return this.default;
throw APIError.create('field_missing', null, {
key: this.srckey,
});
}
if ( typeof value === 'string' ) {
if (
value === 'true' || value === '1' || value === 'yes'
) return true;
if (
value === 'false' || value === '0' || value === 'no'
) return false;
throw APIError.create('field_invalid', null, {
key: this.srckey,
expected: 'boolean',
});
}
if ( typeof value === 'boolean' ) {
return value;
}
log.debug('tried boolean', { value });
throw APIError.create('field_invalid', null, {
key: this.srckey,
expected: 'boolean',
});
}
};
================================================
FILE: src/backend/src/api/filesystem/StringParam.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
module.exports = class StringParam {
constructor (srckey, options) {
this.srckey = srckey;
this.options = options ?? {};
this.optional = this.options.optional ?? false;
}
async consolidate ({ req, getParam }) {
const log = globalThis.services.get('log-service').create('string-param');
const value = getParam(this.srckey);
if ( value === undefined ) {
if ( this.optional ) return undefined;
throw APIError.create('field_missing', null, {
key: this.srckey,
});
}
if ( value.length === 0 ) {
if ( this.optional ) return undefined;
APIError.create('field_empty', null, {
key: this.srckey,
});
}
if ( typeof value !== 'string' ) {
log.debug('tried string', { value });
throw APIError.create('field_invalid', null, {
key: this.srckey,
expected: 'string',
});
}
return value;
}
};
================================================
FILE: src/backend/src/api/filesystem/UserParam.js
================================================
/*
* 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 .
*/
module.exports = class UserParam {
consolidate ({ req }) {
return req.user;
}
};
================================================
FILE: src/backend/src/boot/BootLogger.js
================================================
/*
* 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 .
*/
class BootLogger {
info (...args) {
console.log('\x1B[36;1m[BOOT/INFO]\x1B[0m',
...args);
}
debug (...args) {
if ( ! process.env.DEBUG ) return;
console.log('\x1B[37m[BOOT/DEBUG]', ...args, '\x1B[0m');
}
error (...args) {
console.log('\x1B[31;1m[BOOT/ERROR]\x1B[0m',
...args);
}
warn (...args) {
console.log('\x1B[33;1m[BOOT/WARN]\x1B[0m',
...args);
}
}
module.exports = {
BootLogger,
};
================================================
FILE: src/backend/src/boot/RuntimeEnvironment.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { quot } = require('@heyputer/putility').libs.string;
const { TechnicalError } = require('../errors/TechnicalError');
const { print_error_help } = require('../errors/error_help_details');
const default_config = require('./default_config');
const config = require('../config');
const { ConfigLoader } = require('../config/ConfigLoader');
// highlights a string
const hl = s => `\x1b[33;1m${s}\x1b[0m`;
// Save the original working directory
const original_cwd = process.cwd();
// === [ Puter Runtime Environment ] ===
// This file contains the RuntimeEnvironment class which is
// responsible for locating the configuration and runtime
// directories for the Puter Kernel.
// Depending on which path we're checking for configuration
// or runtime from config_paths, there will be different
// requirements. These are all possible requirements.
//
// Each check may result in the following:
// - false: this is not the desired path; skip it
// - true: this is the desired path, and it's valid
// - throw: this is the desired path, but it's invalid
const path_checks = ({ logger }) => ({ fs, path_ }) => ({
require_if_not_undefined: ({ path }) => {
if ( path == undefined ) return false;
const exists = fs.existsSync(path);
if ( ! exists ) {
throw new Error(`Path does not exist: ${path}`);
}
return true;
},
skip_if_not_exists: ({ path }) => {
const exists = fs.existsSync(path);
return exists;
},
skip_if_not_in_repo: ({ path }) => {
const exists = fs.existsSync(path_.join(path, '../../.is_puter_repository'));
return exists;
},
require_read_permission: ({ path }) => {
try {
fs.readdirSync(path);
} catch (e) {
throw new Error(`Cannot readdir on path: ${path}`);
}
return true;
},
require_write_permission: ({ path }) => {
try {
fs.writeFileSync(path_.join(path, '.tmp_test_write_permission'), 'test');
fs.unlinkSync(path_.join(path, '.tmp_test_write_permission'));
} catch (e) {
throw new Error(`Cannot write to path: ${path}`);
}
return true;
},
contains_config_file: ({ path }) => {
const valid_config_names = [
'config.json',
'config.json5',
];
for ( const name of valid_config_names ) {
const exists = fs.existsSync(path_.join(path, name));
if ( exists ) {
return true;
}
}
throw new Error(`No valid config file found in path: ${path}`);
},
env_not_set: name => () => {
return !process.env[name];
},
});
// Configuration paths in order of precedence.
// We will load configuration from the first path that's suitable.
const config_paths = ({ path_checks }) => ({ path_ }) => [
{
label: '$CONFIG_PATH',
get path () {
return process.env.CONFIG_PATH;
},
checks: [
path_checks.require_if_not_undefined,
],
},
{
path: '/etc/puter',
checks: [ path_checks.skip_if_not_exists ],
},
{
get path () {
return path_.join(original_cwd, 'volatile/config');
},
checks: [ path_checks.skip_if_not_in_repo ],
},
{
get path () {
return path_.join(original_cwd, 'config');
},
checks: [ path_checks.skip_if_not_exists ],
},
];
const valid_config_names = [
'config.json',
'config.json5',
];
// Suitable working directories in order of precedence.
// We will `process.chdir` to the first path that's suitable.
const runtime_paths = ({ path_checks }) => ({ path_ }) => [
{
label: '$RUNTIME_PATH',
get path () {
return process.env.RUNTIME_PATH;
},
checks: [
path_checks.require_if_not_undefined,
],
},
{
path: '/var/puter',
checks: [
path_checks.skip_if_not_exists,
path_checks.env_not_set('NO_VAR_RUNTIME'),
],
},
{
get path () {
return path_.join(original_cwd, 'volatile/runtime');
},
checks: [ path_checks.skip_if_not_in_repo ],
},
{
get path () {
return path_.join(original_cwd, 'runtime');
},
checks: [ path_checks.skip_if_not_exists ],
},
];
// Suitable mod paths in order of precedence.
const mod_paths = ({ path_checks, entry_path }) => ({ path_ }) => [
{
label: '$MOD_PATH',
get path () {
return process.env.MOD_PATH;
},
checks: [
path_checks.require_if_not_undefined,
],
},
{
path: '/var/puter/mods',
checks: [
path_checks.skip_if_not_exists,
path_checks.env_not_set('NO_VAR_MODS'),
],
},
{
get path () {
return path_.join(path_.dirname(entry_path || require.main.filename), '../mods');
},
checks: [ path_checks.skip_if_not_exists ],
},
];
class RuntimeEnvironment extends AdvancedBase {
static MODULES = {
fs: require('node:fs'),
path_: require('node:path'),
crypto: require('node:crypto'),
format: require('string-template'),
};
constructor ({ logger, entry_path, boot_parameters }) {
super();
this.logger = logger;
this.entry_path = entry_path;
this.boot_parameters = boot_parameters;
this.path_checks = path_checks(this)(this.modules);
this.config_paths = config_paths(this)(this.modules);
this.runtime_paths = runtime_paths(this)(this.modules);
this.mod_paths = mod_paths(this)(this.modules);
}
init () {
try {
return this.init_();
} catch (e) {
this.logger.error(e);
print_error_help(e);
process.exit(1);
}
}
init_ () {
// This variable, called "environment", will be passed back to Kernel
// with some helpful values. A partial-population of this object later
// in this function will be used when evaluating configured paths.
const environment = {};
environment.source = this.modules.path_.dirname(this.entry_path || require.main.filename);
environment.repo = this.modules.path_.dirname(environment.source);
const config_path_entry = this.get_first_suitable_path_({ pathFor: 'configuration' },
this.config_paths,
[
this.path_checks.require_read_permission,
// this.path_checks.contains_config_file,
]);
// Note: there used to be a 'mods_path_entry' here too
// but it was never used
const pwd_path_entry = this.get_first_suitable_path_({ pathFor: 'working directory' },
this.runtime_paths,
[ this.path_checks.require_write_permission ]);
process.chdir(pwd_path_entry.path);
// Check for a valid config file in the config path
let using_config;
for ( const name of valid_config_names ) {
const exists = this.modules.fs.existsSync(this.modules.path_.join(config_path_entry.path, name));
if ( exists ) {
using_config = name;
break;
}
}
const owrite_config = this.boot_parameters.args.overwriteConfig;
const { fs, path_, crypto } = this.modules;
if ( !using_config || owrite_config ) {
const generated_values = {};
generated_values.cookie_name = crypto.randomUUID();
generated_values.jwt_secret = crypto.randomUUID();
generated_values.url_signature_secret = crypto.randomUUID();
generated_values.private_uid_secret = crypto.randomBytes(24).toString('hex');
generated_values.private_uid_namespace = crypto.randomUUID();
if ( using_config ) {
this.logger.debug(`Overwriting ${quot(using_config)} because ` +
`${hl('--overwrite-config')} is set`);
// make backup
fs.copyFileSync(path_.join(config_path_entry.path, using_config),
path_.join(config_path_entry.path, `${using_config }.bak`));
// preserve generated values
{
const config_raw = fs.readFileSync(path_.join(config_path_entry.path, using_config),
'utf8');
const config_values = JSON.parse(config_raw);
for ( const k in generated_values ) {
if ( ! config_values[k] ) continue;
generated_values[k] = config_values[k];
}
}
}
const generated_config = {
...default_config,
...generated_values,
};
generated_config[''] = null; // for trailing comma
fs.writeFileSync(path_.join(config_path_entry.path, 'config.json'),
`${JSON.stringify(generated_config, null, 4) }\n`);
using_config = 'config.json';
}
let config_to_load = 'config.json';
if ( process.env.PUTER_CONFIG_PROFILE ) {
this.logger.debug(`${hl('PROFILE') } ${
quot(process.env.PUTER_CONFIG_PROFILE) } ` +
'because $PUTER_CONFIG_PROFILE is set');
config_to_load = `${process.env.PUTER_CONFIG_PROFILE}.json`;
const exists = fs.existsSync(path_.join(config_path_entry.path, config_to_load));
if ( ! exists ) {
fs.writeFileSync(path_.join(config_path_entry.path, config_to_load),
`${JSON.stringify({
config_name: process.env.PUTER_CONFIG_PROFILE,
$imports: ['config.json'],
}, null, 4) }\n`);
}
}
environment.config_path = path_.join(config_path_entry.path, config_to_load);
const loader = new ConfigLoader(this.logger, config_path_entry.path, config);
loader.enable(config_to_load);
if ( ! config.config_name ) {
throw new Error('config_name is required');
}
this.logger.debug(`${hl('config name') } ${quot(config.config_name)}`);
const mod_paths = [];
environment.mod_paths = mod_paths;
// Trying this as a default for now...
if ( ! config.mod_directories ) {
config.mod_directories = [
'{source}/../mods/mods_enabled',
'{source}/../extensions',
];
}
// If configured, add a user-specified mod path
if ( config.mod_directories ) {
for ( const dir of config.mod_directories ) {
const mods_directory = this.modules.format(dir, environment);
mod_paths.push(mods_directory);
}
}
return environment;
}
get_first_suitable_path_ (meta, paths, last_checks) {
for ( const entry of paths ) {
const checks = [...(entry.checks ?? []), ...last_checks];
this.logger.debug(`Checking path ${quot(entry.label ?? entry.path)} for ${meta.pathFor}...`);
let checks_pass = true;
for ( const check of checks ) {
this.logger.debug(`-> doing ${quot(check.name)} on path ${quot(entry.path)}...`);
const result = check(entry);
if ( result === false ) {
this.logger.debug(`-> ${quot(check.name)} doesn't like this path`);
checks_pass = false;
break;
}
}
if ( ! checks_pass ) continue;
this.logger.info(`${hl(meta.pathFor)} ${quot(entry.path)}`);
return entry;
}
if ( meta.optional ) return;
throw new TechnicalError(`No suitable path found for ${meta.pathFor}.`);
}
}
module.exports = {
RuntimeEnvironment,
};
================================================
FILE: src/backend/src/boot/default_config.js
================================================
/*
* 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 .
*/
module.exports = {
config_name: 'generated default config',
env: 'dev',
nginx_mode: true, // really means "serve http instead of https"
server_id: 'localhost',
http_port: 'auto',
domain: 'puter.localhost',
protocol: 'http',
contact_email: 'hey@example.com',
services: {
database: {
engine: 'sqlite',
path: 'puter-database.sqlite',
},
dynamo: {
path: './puter-ddb',
},
},
};
================================================
FILE: src/backend/src/clients/dynamodb/.gitignore
================================================
*.js
*.js.map
================================================
FILE: src/backend/src/clients/dynamodb/DDBClient.ts
================================================
import { CreateTableCommand, CreateTableCommandInput, DynamoDBClient, UpdateTimeToLiveCommand } from '@aws-sdk/client-dynamodb';
import { BatchGetCommand, BatchGetCommandInput, DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import dynalite from 'dynalite';
import { once } from 'node:events';
import { Agent as httpsAgent } from 'node:https';
interface DBClientConfig {
aws?: {
access_key: string
secret_key: string
region: string
},
path?: string,
endpoint?: string
}
const LOCAL_DYNAMO_PATH_KEY = ':memory:';
const localDynaliteEndpointPromises = new Map>();
const getDynalitePathKey = (path?: string) => {
if ( path === ':memory:' ) return LOCAL_DYNAMO_PATH_KEY;
return path || './puter-ddb';
};
const getOrCreateLocalDynaliteEndpoint = async (pathKey: string) => {
let endpointPromise = localDynaliteEndpointPromises.get(pathKey);
if ( endpointPromise ) return endpointPromise;
endpointPromise = (async () => {
const dynaliteOptions = pathKey === LOCAL_DYNAMO_PATH_KEY
? { createTableMs: 0 }
: { createTableMs: 0, path: pathKey };
const dynaliteInstance = dynalite(dynaliteOptions);
const dynaliteServer = dynaliteInstance.listen(0, '127.0.0.1');
// Don't keep test workers alive just because dynalite is still open.
dynaliteServer.unref?.();
await once(dynaliteServer, 'listening');
const address = dynaliteServer.address();
const port = (typeof address === 'object' && address ? address.port : undefined) || 4567;
return `http://127.0.0.1:${port}`;
})();
localDynaliteEndpointPromises.set(pathKey, endpointPromise);
endpointPromise.catch(() => {
if ( localDynaliteEndpointPromises.get(pathKey) === endpointPromise ) {
localDynaliteEndpointPromises.delete(pathKey);
}
});
return endpointPromise;
};
export class DDBClient {
ddbClientPromise: Promise;
#documentClient!: DynamoDBDocumentClient;
config?: DBClientConfig;
constructor (config?: DBClientConfig) {
this.config = config;
this.ddbClientPromise = this.#getClient();
this.ddbClientPromise.then(client => {
this.#documentClient = DynamoDBDocumentClient.from(client, {
marshallOptions: {
removeUndefinedValues: true,
} });
});
}
async recreateClient () {
this.ddbClientPromise = this.#getClient();
this.#documentClient = DynamoDBDocumentClient.from(await this.ddbClientPromise, {
marshallOptions: {
removeUndefinedValues: true,
} });
}
async #getClient () {
if ( ! this.config?.aws ) {
console.warn('No config for DynamoDB, will fall back on local dynalite');
const pathKey = getDynalitePathKey(this.config?.path);
const dynamoEndpoint = await getOrCreateLocalDynaliteEndpoint(pathKey);
const client = new DynamoDBClient({
credentials: {
accessKeyId: 'fake',
secretAccessKey: 'fake',
},
maxAttempts: 3,
requestHandler: new NodeHttpHandler({
connectionTimeout: 5000,
requestTimeout: 5000,
httpsAgent: new httpsAgent({ keepAlive: true }),
}),
endpoint: dynamoEndpoint,
region: 'us-west-2',
});
console.log(`Dynalite client created within instance for region: ${await client.config.region()}`);
return client;
}
const client = new DynamoDBClient({
credentials: {
accessKeyId: this.config.aws.access_key,
secretAccessKey: this.config.aws.secret_key,
},
maxAttempts: 3,
requestHandler: new NodeHttpHandler({
connectionTimeout: 5000,
requestTimeout: 5000,
httpsAgent: new httpsAgent({ keepAlive: true }),
}),
...(this.config.endpoint ? { endpoint: this.config.endpoint } : {}),
region: this.config.aws.region || 'us-west-2',
});
console.log(`DynamoDB client created with region ${await client.config.region()}`);
return client;
}
async get >(table: string, key: T, consistentRead = false) {
const command = new GetCommand({
TableName: table,
Key: key,
ConsistentRead: consistentRead,
ReturnConsumedCapacity: 'TOTAL',
});
const response = await this.#documentClient.send(command);
return response;
}
async put >(table: string, item: T) {
const command = new PutCommand({
TableName: table,
Item: item,
ReturnConsumedCapacity: 'TOTAL',
});
const response = await this.#documentClient.send(command);
return response;
}
async batchGet (params: { table: string, items: Record }[], consistentRead = false) {
// TODO DS: implement chunking for more than 100 items or more than allowed req size
const allRequestItemsPerTable = params.reduce((acc, curr) => {
if ( ! acc[curr.table] ) acc[curr.table] = [];
acc[curr.table].push(curr.items);
return acc;
}, {} as Record[]>);
const RequestItems: BatchGetCommandInput['RequestItems'] = Object.entries(allRequestItemsPerTable).reduce(
(acc, [table, keyList]) => {
const Keys = keyList;
acc[table] = {
Keys,
ConsistentRead: consistentRead,
};
return acc;
},
{} as NonNullable,
);
const command = new BatchGetCommand({
RequestItems,
ReturnConsumedCapacity: 'TOTAL',
});
return this.#documentClient.send(command);
}
async del> (table: string, key: T) {
const command = new DeleteCommand({
TableName: table,
Key: key,
ReturnConsumedCapacity: 'TOTAL',
});
return this.#documentClient.send(command);
}
async query> (
table: string,
keys: T,
limit = 0,
pageKey?: Record,
index = '',
consistentRead = false,
options?: { beginsWith?: { key: string; value: string } },
) {
const keyExpressionParts = Object.keys(keys).map(key => `#${key} = :${key}`);
const expressionAttributeValues = Object.entries(keys).reduce((acc, [key, value]) => {
acc[`:${key}`] = value;
return acc;
}, {});
const expressionAttributeNames = Object.keys(keys).reduce((acc, key) => {
acc[`#${key}`] = key;
return acc;
}, {});
if ( options?.beginsWith?.key && typeof options.beginsWith.value === 'string' && options.beginsWith.value !== '' ) {
const beginsKey = options.beginsWith.key;
const beginsValueToken = `:${beginsKey}_begins_with`;
keyExpressionParts.push(`begins_with(#${beginsKey}, ${beginsValueToken})`);
expressionAttributeValues[beginsValueToken] = options.beginsWith.value;
expressionAttributeNames[`#${beginsKey}`] = beginsKey;
}
const keyExpression = keyExpressionParts.join(' AND ');
const command = new QueryCommand({
TableName: table,
...(!index ? {} : { IndexName: index }),
KeyConditionExpression: keyExpression,
ExpressionAttributeValues: expressionAttributeValues,
ExpressionAttributeNames: expressionAttributeNames,
ConsistentRead: consistentRead,
...(!pageKey ? {} : { ExclusiveStartKey: pageKey }),
...(!limit ? {} : { Limit: limit }),
ReturnConsumedCapacity: 'TOTAL',
});
return await this.#documentClient.send(command);
}
async update> (
table: string,
key: T,
expression: string,
expressionValues?: Record,
expressionNames?: Record,
) {
const hasValues = !!expressionValues && Object.keys(expressionValues).length > 0;
const hasNames = !!expressionNames && Object.keys(expressionNames).length > 0;
const command = new UpdateCommand({
TableName: table,
Key: key,
UpdateExpression: expression,
...(hasValues ? { ExpressionAttributeValues: expressionValues } : {}),
...(hasNames ? { ExpressionAttributeNames: expressionNames } : {}),
ReturnValues: 'ALL_NEW',
ReturnConsumedCapacity: 'TOTAL',
});
try {
return await this.#documentClient.send(command);
} catch ( e ) {
console.error('DDB Update Error', e);
throw e;
}
}
async createTableIfNotExists (params: CreateTableCommandInput, ttlAttribute?: string) {
if ( this.config?.aws ) {
console.warn('Creating DynamoDB tables in AWS is disabled by default, but if you need to enable it, modify the DDBClient class');
return;
}
try {
await this.#documentClient.send(new CreateTableCommand(params));
} catch ( e ) {
if ( (e as Error)?.name !== 'ResourceInUseException' ) {
throw e;
}
setTimeout(async () => {
if ( ttlAttribute ) {
// ensure TTL is set
await this.#documentClient.send(new UpdateTimeToLiveCommand({
TableName: params.TableName!,
TimeToLiveSpecification: {
AttributeName: ttlAttribute,
Enabled: true,
},
}));
}
}, 5000); // wait 5 seconds to ensure table is active
}
}
}
================================================
FILE: src/backend/src/clients/dynamodb/DDBClientWrapper.ts
================================================
import { BaseService } from '@heyputer/backend/src/services/BaseService.js';
import { DDBClient } from './DDBClient.js';
/** Wrapping actual implementation to be usable through our core structure */
class DDBClientServiceWrapper extends BaseService {
ddbClient!: DDBClient;
async _construct () {
this.ddbClient = new DDBClient(this.config as unknown as ConstructorParameters[0]);
await this.ddbClient.ddbClientPromise; // ensure client is ready
Object.getOwnPropertyNames(DDBClient.prototype).forEach(fn => {
if ( fn === 'constructor' ) return;
this[fn] = (...args: unknown[]) => this.ddbClient[fn](...args);
});
}
}
export const DDBClientWrapper = DDBClientServiceWrapper as unknown as DDBClient;
================================================
FILE: src/backend/src/clients/redis/.gitignore
================================================
*.js
*.js.map
================================================
FILE: src/backend/src/clients/redis/cacheUpdate.ts
================================================
/*
* 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 .
*/
import type { EventService } from '../../services/EventService.js';
import { Context } from '../../util/context.js';
import { redisClient } from './redisSingleton.js';
type CacheKeyInput = string | number | null | undefined | CacheKeyInput[];
interface CacheUpdateOptions {
eventService?: EventService,
emitEvent?: boolean,
}
const SERVICES_KEY = Symbol.for('puter.helpers.services');
const flattenCacheKeys = (inputs: CacheKeyInput[]): Array => {
const flattened: Array = [];
for ( const input of inputs ) {
if ( Array.isArray(input) ) {
flattened.push(...flattenCacheKeys(input));
continue;
}
flattened.push(input);
}
return flattened;
};
export const normalizeCacheKeys = (cacheKey: CacheKeyInput | CacheKeyInput[]): string[] => {
const arr = Array.isArray(cacheKey) ? cacheKey : [cacheKey];
return [...new Set(flattenCacheKeys(arr)
.map(key => key === null || key === undefined ? '' : String(key))
.filter(Boolean))];
};
const getEventService = (eventService?: CacheUpdateOptions['eventService']) => {
if ( eventService?.emit ) return eventService;
const contextServices = Context.get('services', { allow_fallback: true });
if ( contextServices?.get ) {
try {
return contextServices.get('event');
} catch (e) {
// no-op
}
}
const globalServices = (globalThis)[SERVICES_KEY]?.services as typeof contextServices;
if ( globalServices?.get ) {
try {
return globalServices.get('event');
} catch (e) {
// no-op
}
}
return null;
};
export const emitOuterCacheUpdate = (
{
cacheKey,
data,
ttlSeconds,
}: {
cacheKey: CacheKeyInput | CacheKeyInput[],
data?: unknown,
ttlSeconds?: number,
},
{
eventService,
emitEvent = true,
}: CacheUpdateOptions = {},
) => {
if ( ! emitEvent ) return;
const keys = normalizeCacheKeys(cacheKey);
if ( ! keys.length ) return;
const svc_event = getEventService(eventService);
if ( ! svc_event ) return;
const payload: Record = { cacheKey: keys };
if ( data !== undefined ) payload.data = data;
if ( ttlSeconds !== undefined && ttlSeconds !== null ) {
payload.ttlSeconds = ttlSeconds;
}
svc_event.emit('outer.cacheUpdate', payload);
};
export const setRedisCacheValue = async (
key: string,
value: string | number,
{
ttlSeconds,
}: {
ttlSeconds?: number,
eventData?: unknown,
eventService?: CacheUpdateOptions['eventService'],
emitEvent?: boolean,
} = {},
) => {
if ( ttlSeconds ) {
await redisClient.set(key, value, 'EX', ttlSeconds);
} else {
await redisClient.set(key, value);
}
};
================================================
FILE: src/backend/src/clients/redis/deleteRedisKeys.ts
================================================
/*
* 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 .
*/
import { EventService } from '../../services/EventService.js';
import { redisClient } from './redisSingleton.js';
type DeleteRedisKeysInput = string | number | null | undefined | DeleteRedisKeysInput[];
interface DeleteRedisKeysOptions {
emitEvent?: boolean,
eventService?: EventService,
}
const isDeleteOptions = (value: unknown): value is DeleteRedisKeysOptions => {
return !!value
&& typeof value === 'object'
&& !Array.isArray(value)
&& (
Object.prototype.hasOwnProperty.call(value, 'emitEvent') ||
Object.prototype.hasOwnProperty.call(value, 'eventService')
);
};
const flattenInputs = (inputs: DeleteRedisKeysInput[]): Array => {
const flattened: Array = [];
for ( const input of inputs ) {
if ( Array.isArray(input) ) {
flattened.push(...flattenInputs(input));
continue;
}
flattened.push(input);
}
return flattened;
};
export const deleteRedisKeys = async (...inputs: (DeleteRedisKeysInput | DeleteRedisKeysOptions)[]) => {
const keysInput = [...inputs];
if ( isDeleteOptions(keysInput[keysInput.length - 1]) ) {
keysInput.pop() as DeleteRedisKeysOptions;
}
const keys = flattenInputs(keysInput as DeleteRedisKeysInput[])
.map(key => key === null || key === undefined ? '' : String(key))
.filter(Boolean);
if ( keys.length === 0 ) {
return 0;
}
const uniqueKeys = [...new Set(keys)];
const deleteResults = await Promise.allSettled(uniqueKeys.map(key => redisClient.del(key)));
const deleted = deleteResults.reduce((sum, promiseCount) => sum + (promiseCount.status === 'fulfilled' ? promiseCount.value : 0), 0);
return deleted;
};
================================================
FILE: src/backend/src/clients/redis/redisSingleton.test.ts
================================================
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const redisMocks = vi.hoisted(() => {
const redisClusterInstances: Array<{
on: ReturnType;
once: ReturnType;
}> = [];
return {
redisClusterInstances,
redisClusterConstructorMock: vi.fn(),
mockRedisClusterConstructorMock: vi.fn(),
};
});
vi.mock('ioredis', () => {
class RedisClusterMock {
on = vi.fn().mockReturnThis();
once = vi.fn().mockReturnThis();
constructor (...args: unknown[]) {
redisMocks.redisClusterConstructorMock(...args);
redisMocks.redisClusterInstances.push(this);
}
}
return {
default: {
Cluster: RedisClusterMock,
},
};
});
vi.mock('ioredis-mock', () => {
class MockRedisClusterMock {
constructor (...args: unknown[]) {
redisMocks.mockRedisClusterConstructorMock(...args);
}
}
return {
default: {
Cluster: MockRedisClusterMock,
},
};
});
describe('redisSingleton', () => {
const initialRedisConfig = process.env.REDIS_CONFIG;
beforeEach(() => {
vi.resetModules();
redisMocks.redisClusterInstances.length = 0;
redisMocks.redisClusterConstructorMock.mockReset();
redisMocks.mockRedisClusterConstructorMock.mockReset();
process.env.REDIS_CONFIG = JSON.stringify([{ host: '127.0.0.1', port: 6379 }]);
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
if ( initialRedisConfig === undefined ) {
delete process.env.REDIS_CONFIG;
} else {
process.env.REDIS_CONFIG = initialRedisConfig;
}
vi.restoreAllMocks();
});
it('uses resilient cluster options and registers startup-safe listeners', async () => {
const singletonModule = await import('./redisSingleton.ts');
expect(redisMocks.redisClusterConstructorMock).toHaveBeenCalledTimes(1);
const [startupNodes, clusterOptions] = redisMocks.redisClusterConstructorMock.mock.calls[0];
expect(startupNodes).toEqual([{ host: '127.0.0.1', port: 6379 }]);
expect(clusterOptions).toEqual(expect.objectContaining({
enableOfflineQueue: true,
retryDelayOnFailover: 500,
retryDelayOnClusterDown: 1000,
retryDelayOnTryAgain: 300,
slotsRefreshTimeout: 5000,
clusterRetryStrategy: expect.any(Function),
dnsLookup: expect.any(Function),
redisOptions: expect.objectContaining({
connectTimeout: 10000,
maxRetriesPerRequest: null,
tls: {},
}),
}));
expect(clusterOptions.clusterRetryStrategy(1)).toBe(200);
expect(clusterOptions.clusterRetryStrategy(100)).toBe(2000);
const clusterInstance = redisMocks.redisClusterInstances[0];
expect(singletonModule.redisClient).toBe(clusterInstance);
expect(clusterInstance.once).toHaveBeenCalledWith('connect', expect.any(Function));
expect(clusterInstance.once).toHaveBeenCalledWith('ready', expect.any(Function));
expect(clusterInstance.on).toHaveBeenCalledWith('error', expect.any(Function));
expect(clusterInstance.on).toHaveBeenCalledWith('node error', expect.any(Function));
});
});
================================================
FILE: src/backend/src/clients/redis/redisSingleton.ts
================================================
import Redis, { Cluster } from 'ioredis';
import MockRedis from 'ioredis-mock';
const redisStartupRetryMaxDelayMs = 2000;
const redisSlotsRefreshTimeoutMs = 5000;
const redisConnectTimeoutMs = 10000;
const redisBootRetryRegex = /Cluster(All)?FailedError|None of startup nodes is available/i;
const formatRedisError = (error: unknown): string => {
if ( error instanceof Error ) {
return `${error.name}: ${error.message}`;
}
return String(error);
};
const attachClusterEventHandlers = (clusterClient: Cluster): void => {
clusterClient.once('connect', () => {
console.log('[redis] cluster transport connected');
});
clusterClient.once('ready', () => {
console.log('[redis] cluster ready');
});
clusterClient.on('error', (error: unknown) => {
const errorText = formatRedisError(error);
if ( redisBootRetryRegex.test(errorText) ) {
console.warn(`[redis] startup issue while connecting to cluster; retrying automatically (${errorText})`);
return;
}
console.error('[redis] cluster error', error);
});
clusterClient.on('node error', (error: unknown, nodeKey: string) => {
const errorText = formatRedisError(error);
if ( redisBootRetryRegex.test(errorText) ) {
console.warn(`[redis] startup issue for cluster node ${nodeKey}; retrying automatically (${errorText})`);
return;
}
console.error(`[redis] cluster node error (${nodeKey})`, error);
});
};
let redisOpt: Cluster;
if ( process.env.REDIS_CONFIG ) {
const redisConfig = JSON.parse(process.env.REDIS_CONFIG);
redisOpt = new Redis.Cluster(redisConfig, {
dnsLookup: (address, callback) => callback(null, address),
clusterRetryStrategy: (attempts) => Math.min(100 + (attempts * 100), redisStartupRetryMaxDelayMs),
retryDelayOnFailover: 500,
retryDelayOnClusterDown: 1000,
retryDelayOnTryAgain: 300,
slotsRefreshTimeout: redisSlotsRefreshTimeoutMs,
enableOfflineQueue: true,
redisOptions: {
tls: {},
connectTimeout: redisConnectTimeoutMs,
maxRetriesPerRequest: null,
},
});
attachClusterEventHandlers(redisOpt);
console.log('connecting to redis from config');
} else {
redisOpt = new MockRedis.Cluster(['redis://localhost:7001']);
console.log('connected to local redis mock');
}
export const redisClient = redisOpt;
================================================
FILE: src/backend/src/codex/CodeUtil.js
================================================
/*
* 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 .
*/
class CodeUtil {
/**
* Wrap a method*[1] with an implementation of a runnable class.
* The wrapper must be a class that implements `async run(values)`,
* and `run` should delegate to `this._run()` after setting this.values.
* The `BaseOperation` class is an example of such a class.
*
* [1]: since our runnable interface expects named parameters, this
* wrapping behavior is only useful for methods that accept a single
* object argument.
* @param {*} method
* @param {*} wrapper
*/
static mrwrap (method, wrapper, options = {}) {
const cls_name = options.name || method.name;
const cls = class extends wrapper {
async _run () {
return await method.call(this.self, this.values);
}
};
Object.defineProperty(cls, 'name', { value: cls_name });
return async function (...a) {
const op = new cls();
// eslint-disable-next-line no-invalid-this
op.self = this; // TODO: fix this odd structure, what is this even bound to ?
return await op.run(...a);
};
}
}
module.exports = {
CodeUtil,
};
================================================
FILE: src/backend/src/codex/README.md
================================================
# What is this?
ChatGPT told me to call this codex and that sounds really cool so
I couldn't resist.
This directory contains utilities for modelling code as data, so that
we can use static analysis techniques and prevent detectable errors
from reaching produciton. This is an attempt at making things more robust,
but it's not guarenteed to work or even be useful; we need to try it and
collect data about its effectiveness.
================================================
FILE: src/backend/src/codex/Sequence.js
================================================
/*
* 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 .
*/
/**
* @typedef {Object} A
* @property {(key: string) => unknown} get - Get a value from the sequence scope.
* @property {function(string, any): void} set - Set a value in the sequence scope.
* @property {(valsToSet?: T) => T extends undefined ? unknown : T} values - Get or set multiple values in the sequence scope.
* @property {function(string=): any} iget - Get a value from the instance (thisArg).
* @property {(methodName: string, ...params: any[] ) => any} icall - Call a method on the instance (thisArg).
* @property {function(string, ...any): any} idcall - Call a method on the instance with the sequence state as the first argument.
* @property {Object} log - Logger, if available on the instance.
* @property {function(any): any} stop - Stop the sequence early and optionally return a value.
* @property {number} i - Current step index.
*/
/**
* @typedef {(...args: any) => Promise} SequenceCallable
* A callable function returned by the Sequence constructor.
* @param {Object|Sequence.SequenceState} [opt_values] - Initial values for the sequence scope, or a SequenceState.
* @returns {Promise} The return value of the last step in the sequence.
*/
/**
* Sequence is a callable object that executes a series of functions in order.
* The functions are expected to be asynchronous; if they're not it might still
* work, but it's neither tested nor supported.
*
* Note: arrow functions are supported, but they are not recommended;
* using keyword functions allows each step to be named.
*
* Example usage:
*
* const seq = new Sequence([
* async function set_foo (a) {
* a.set('foo', 'bar')
* },
* async function print_foo (a) {
* console.log(a.get('foo'));
* },
* async function third_step (a) {
* // do something
* },
* ]);
*
* await seq();
*
* Example with controlled conditional branches:
*
* const seq = new Sequence([
* async function first_step (a) {
* // do something
* },
* {
* condition: async a => a.get('foo') === 'bar',
* fn: async function second_step (a) {
* // do something
* }
* },
* async function third_step (a) {
* // do something
* },
* ]);
*
* If it is called with an argument, it must be an object containing values
* which will populate the "sequence scope".
*
* If it is called on an instance with a member called `values`
* (i.e. if `this.values` is defined), then these values will populate the
* sequence scope. This is to maintain compatibility for Sequence to be used
* as an implementation of a runnable class. (See CodeUtil.mrwrap or BaseOperation)
*
* The object returned by the constructor is a function, which is used to
* make the object callable. The callable object will execute the sequence
* when called. The return value of the sequence is the return value of the
* last function in the sequence.
*
* Each function in the sequence is passed a SequenceState object
* as its first argument. Conventionally, this argument is called `a`,
* which is short for either "API", "access", or "the `a` variable"
* depending on which you prefer. Sequence provides methods for accessing
* the sequence scope.
*
* By accessing the sequence scope through the `a` variable, changes to the
* sequence scope can be monitored and recorded. (TODO: implement observe methods)
*/
/**
* Sequence is a callable object that executes a series of asynchronous functions in order.
* Each function receives a SequenceState instance for accessing and mutating the sequence scope.
* Supports conditional steps, deferred steps, and can be used as a runnable implementation for classes.
* @class @extends Function
*/
class Sequence {
/**
* SequenceState represents the state of a Sequence execution.
* Provides access to the sequence scope, step control, and utility methods for step functions.
*/
static SequenceState = class SequenceState {
/**
* Create a new SequenceState.
* @param {Sequence|function} sequence - The Sequence instance or its callable function.
* @param {Object} [thisArg] - The instance to bind as `this` for step functions.
*/
constructor (sequence, thisArg) {
if ( typeof sequence === 'function' ) {
sequence = sequence.sequence;
}
this.sequence_ = sequence;
this.thisArg = thisArg;
this.steps_ = null;
this.value_history_ = [];
this.scope_ = {};
this.last_return_ = undefined;
this.i = 0;
this.stopped_ = false;
this.defer_ptr_ = undefined;
this.defer = this.constructor.defer_0;
}
/**
* Get the current steps array for this sequence execution.
* @returns {Array} The steps to execute.
*/
get steps () {
return this.steps_ ?? this.sequence_?.steps_;
}
/**
* Run the sequence from the current step index.
* @param {Object} [values] - Initial values for the sequence scope.
* @returns {Promise}
*/
async run (values) {
// Initialize scope
values = values || this.thisArg?.values || {};
Object.setPrototypeOf(this.scope_, values);
// Run sequence
for ( ; this.i < this.steps.length ; this.i++ ) {
let step = this.steps[this.i];
if ( typeof step !== 'object' ) {
step = {
name: step.name,
fn: step,
};
}
if ( step.condition && !await step.condition(this) ) {
continue;
}
const parent_scope = this.scope_;
this.scope_ = {};
// We could do Object.assign(this.scope_, parent_scope), but
// setting the prototype should be faster (in theory)
Object.setPrototypeOf(this.scope_, parent_scope);
if ( this.sequence_.options_.record_history ) {
this.value_history_.push(this.scope_);
}
if ( this.sequence_.options_.before_each ) {
await this.sequence_.options_.before_each(this, step);
}
this.last_return_ = await step.fn.call(this.thisArg, this);
if ( this.last_return_ instanceof Sequence.SequenceState ) {
this.scope_ = this.last_return_.scope_;
}
if ( this.sequence_.options_.after_each ) {
await this.sequence_.options_.after_each(this, step);
}
if ( this.stopped_ ) {
break;
}
}
}
// Why check a condition every time code is called,
// when we can check it once and then replace the code?
/**
* The first time defer is called, clones the steps and sets up for deferred insertion.
* @param {function(Sequence.SequenceState): Promise} fn - The function to defer.
*/
static defer_0 = function (fn) {
this.steps_ = [...this.sequence_.steps_];
this.defer = this.constructor.defer_1;
this.defer_ptr_ = this.steps_.length;
this.defer(fn);
};
/**
* Subsequent calls to defer insert the function before the deferred pointer.
* @param {function(Sequence.SequenceState): Promise} fn - The function to defer.
*/
static defer_1 = function (fn) {
// Deferred functions don't affect the return value
const real_fn = fn;
fn = async () => {
await real_fn(this);
return this.last_return_;
};
// Insert deferred step before the pointer
this.steps_.splice(this.defer_ptr_, 0, fn);
};
/**
* Get a value from the sequence scope.
* @param {string} k - The key to retrieve.
* @returns {any} The value associated with the key.
*/
get (k) {
// TODO: record read1
return this.scope_[k];
}
/**
* Set a value in the sequence scope.
* @param {string} k - The key to set.
* @param {any} v - The value to assign.
*/
set (k, v) {
// TODO: record mutation
this.scope_[k] = v;
}
/**
* Get or set multiple values in the sequence scope.
* @param {Object} [opt_itemsToSet] - Optional object of key-value pairs to set.
* @returns {Object} Proxy to the current scope for value access.
*/
values (opt_itemsToSet) {
if ( opt_itemsToSet ) {
for ( const k in opt_itemsToSet ) {
this.set(k, opt_itemsToSet[k]);
}
}
return new Proxy(this.scope_, {
get: (target, property) => {
if ( property in target ) {
// TODO: record read
return target[property];
}
return undefined;
},
});
}
/**
* Get a value from the instance (`thisArg`).
* @param {string} [k] - The property name to retrieve. If omitted, returns the instance.
* @returns {any} The value from the instance or the instance itself.
*/
iget (k) {
if ( k === undefined ) return this.thisArg;
return this.thisArg?.[k];
}
// Instance call: call a method on the instance
/**
* Call a method on the instance (`thisArg`).
* @param {string} k - The method name.
* @param {...any} args - Arguments to pass to the method.
* @returns {any} The result of the method call.
*/
icall (k, ...args) {
return this.thisArg?.[k]?.call(this.thisArg, ...args);
}
// Instance dynamic call: call a method on the instance,
// passing the sequence state as the first argument
/**
* Call a method on the instance, passing the sequence state as the first argument.
* @param {string} k - The method name.
* @param {...any} args - Arguments to pass after the sequence state.
* @returns {any} The result of the method call.
*/
idcall (k, ...args) {
return this.thisArg?.[k]?.call(this.thisArg, this, ...args);
}
/**
* Get the logger from the instance, if available.
* @returns {Object|undefined} The logger object.
*/
get log () {
return this.iget('log');
}
/**
* Stop the sequence early and optionally return a value.
* @param {any} [return_value] - Value to return from the sequence.
* @returns {any} The provided return value.
*/
stop (return_value) {
this.stopped_ = true;
return return_value;
}
};
/**
*
* @param {Array | {condition: (a: A) => boolean | Promise, fn: function(A): Promise}> | function(A): Promise | Object} args
* @returns {Sequence}
*/
/**
* Create a new Sequence.
* @param {...(Array|Object>|function(Sequence.SequenceState): Promise|Object)} args
* - Arrays of step functions or step objects, individual step functions, or options objects.
* - Step objects may have a `condition` property (function) and a `fn` property (function).
* - Options object may include `name`, `record_history`, `before_each`, `after_each`.
* @returns {SequenceCallable} A callable function that runs the sequence.
*/
constructor (...args) {
const sequence = this;
const steps = [];
const options = {};
for ( const arg of args ) {
if ( Array.isArray(arg) ) {
steps.push(...arg);
} else if ( typeof arg === 'object' ) {
Object.assign(options, arg);
} else if ( typeof arg === 'function' ) {
steps.push(arg);
} else {
throw new TypeError(`Invalid argument to Sequence constructor: ${arg}`);
}
}
/**
* Callable function to execute the sequence.
* @param {Object|Sequence.SequenceState} [opt_values] - Initial values or a SequenceState.
* @returns {Promise} The return value of the last step.
*/
const fn = async function (opt_values) {
if ( opt_values && opt_values instanceof Sequence.SequenceState ) {
opt_values = opt_values.scope_;
}
// eslint-disable-next-line no-invalid-this
const state = new Sequence.SequenceState(sequence, this); // TODO: fix this odd structure, what is this even bound to ?
await state.run(opt_values ?? undefined);
return state.last_return_;
};
this.steps_ = steps;
this.options_ = options || {};
Object.defineProperty(fn, 'name', {
value: options.name || 'Sequence',
});
Object.defineProperty(fn, 'sequence', { value: this });
return fn;
}
}
module.exports = {
Sequence,
};
================================================
FILE: src/backend/src/config/ConfigLoader.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { quot } = require('@heyputer/putility').libs.string;
class ConfigLoader extends AdvancedBase {
static MODULES = {
path_: require('path'),
fs: require('fs'),
};
constructor (logger, path, config) {
super();
this.logger = logger;
this.path = path;
this.config = config;
}
enable (name, meta = {}) {
const { path_, fs } = this.modules;
const config_path = path_.join(this.path, name);
if ( ! fs.existsSync(config_path) ) {
throw new Error(`Config file not found: ${config_path}`);
}
const config_values = JSON.parse(fs.readFileSync(config_path, 'utf8'));
if ( config_values.$requires ) {
const config_list = config_values.$requires;
delete config_values.$requires;
this.apply_requires(this.path, config_list, { by: name });
}
this.logger.debug(`Applying config: ${path_.relative(this.path, config_path)}${
meta.by ? ` (required by ${meta.by})` : ''}`);
this.config.load_config(config_values);
}
apply_requires (dir, config_list, { by } = {}) {
const { path_, fs } = this.modules;
for ( const name of config_list ) {
const config_path = path_.join(dir, name);
if ( ! fs.existsSync(config_path) ) {
throw new Error(`could not find ${quot(config_path)} ` +
`required by ${quot(by)}`);
}
this.enable(name, { by });
}
}
}
module.exports = { ConfigLoader };
================================================
FILE: src/backend/src/config/deep_proto_merge.js
================================================
/*
* 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 .
*/
/**
* Sets replacement.__proto__ to `delegate`
* then iterates over members of `replacement` looking for
* objects that are not arrays.
*
* When an object is found, a recursive call is made to
* `deep_proto_merge` with the corresponding object in `delegate`.
*
* If `preserve_flag` is set to true, only objects containing
* a truthy property named `$preserve` will be merged.
*
* @param {*} replacement
* @param {*} delegate
*/
const deep_proto_merge = (replacement, delegate, options) => {
const is_object = (obj) => obj &&
typeof obj === 'object' && !Array.isArray(obj);
replacement.__proto__ = delegate;
for ( const key in replacement ) {
if ( ! is_object(replacement[key]) ) continue;
if ( options?.preserve_flag && !replacement[key].$preserve ) {
continue;
}
if ( ! is_object(delegate[key]) ) {
continue;
}
replacement[key] = deep_proto_merge(replacement[key], delegate[key], options);
}
// use a Proxy object to ensure all keys are present
// when listing keys of `replacement`
replacement = new Proxy(replacement, {
// no get needed
// no set needed
ownKeys: (target) => {
const ownProps = Reflect.ownKeys(target); // Get own property names and symbols, including non-enumerable
const protoProps = Reflect.ownKeys(Object.getPrototypeOf(target)); // Get prototype's properties
// Combine and deduplicate properties using a Set, then convert back to an array
const s = new Set([
...protoProps,
...ownProps,
]);
if ( options?.preserve_flag ) {
// remove $preserve if it exists
s.delete('$preserve');
}
return Array.from(s);
},
getOwnPropertyDescriptor: (target, prop) => {
// Real descriptor
let descriptor = Object.getOwnPropertyDescriptor(target, prop);
if ( descriptor ) return descriptor;
// Immediate prototype descriptor
const proto = Object.getPrototypeOf(target);
descriptor = Object.getOwnPropertyDescriptor(proto, prop);
if ( descriptor ) return descriptor;
return undefined;
},
});
return replacement;
};
module.exports = deep_proto_merge;
================================================
FILE: src/backend/src/config/reserved_words.js
================================================
/*
* 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 .
*/
module.exports = [
// system and apps
'about',
'api',
'camera',
'changelog',
'cloudjs',
'cloud.js',
'code',
'dev-center',
'draw',
'editor',
'markus',
'pdf',
'photopea',
'player',
'terminal',
'viewer',
'www',
// UNIX directories
'share',
'usr',
'dev',
'var',
'etc',
'tmp',
'lib',
'mnt',
'opt',
'bin',
// others
'admin',
'ads',
'alt',
'api',
'app',
'apps',
'audio',
'auth',
'badge',
'beta',
'business',
'buy',
'cdn',
'cli',
'cloud',
'cmd',
'community',
'careers',
'config',
'db',
'demo',
'dev',
'developers',
'dns1',
'dns2',
'dns3',
'dns4',
'dns5',
'dns6',
'dns7',
'dns8',
'dns9',
'dns0',
'doc',
'docs',
'email',
'eng',
'engineering',
'exchange',
'faq',
'feeds',
'files',
'forum',
'fs',
'ftp',
'gov',
'groups',
'help',
'hq',
'images',
'img',
'in',
'inbound',
'info',
'jobs',
'js',
'lab',
'learn',
'live',
'login',
'mail',
'media',
'mobile',
'mx',
'mx1',
'mx2',
'mx3',
'mx4',
'mx5',
'mx6',
'mx7',
'mx8',
'mx9',
'mx0',
'my',
'mysql',
'news',
'newsletter',
'ns1',
'ns2',
'ns3',
'ns4',
'ns5',
'ns6',
'ns7',
'ns8',
'ns9',
'ns0',
'office',
'out',
'owa',
'pop',
'pop3',
'portal',
'private',
'public',
'puter',
'remote',
'sandbox',
'sdk',
'search',
'secure',
'service',
'shell',
'shop',
'signin',
'signup',
'smtp',
'smtpin',
'socket',
'ssl',
'start',
'static',
'status',
'store',
'support',
'test',
'tutorials',
'upload',
'video',
'videos',
'vpn',
'vps',
'web',
'wiki',
'www',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'0',
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
];
================================================
FILE: src/backend/src/config.d.ts
================================================
import { RecursiveRecord } from "./services/MeteringService/types";
type ConfigRecord = RecursiveRecord;
export interface IConfig extends ConfigRecord {
load_config: (o: ConfigRecord) => void;
__set_config_object__: (
object: ConfigRecord,
options?: { replacePrototype?: boolean; useInitialPrototype?: boolean }
) => void;
}
declare const config: IConfig;
export = config;
================================================
FILE: src/backend/src/config.js
================================================
/*
* 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 .
*/
'use strict';
const deep_proto_merge = require('./config/deep_proto_merge');
// const reserved_words = require('./config/reserved_words');
let config = {};
config.__import_identity__ = require('uuid').v4();
// Static defaults
config.servers = [];
config.disable_user_signup = false;
config.default_user_group = '78b1b1dd-c959-44d2-b02c-8735671f9997';
// Will disable the auto-generated temp users. If a user lands on the site, they will be required to sign up or log in.
config.disable_temp_users = false;
config.default_temp_group = 'b7220104-7905-4985-b996-649fdcdb3c8f';
config.max_file_size = 100_000_000_000;
config.max_thumb_size = 1_000;
config.max_fsentry_name_length = 767;
config.username_regex = /^\w+$/;
config.username_max_length = 45;
config.subdomain_regex = /^[a-zA-Z0-9_-]+$/;
config.subdomain_max_length = 60;
config.app_name_regex = /^[a-zA-Z0-9_-]+$/;
config.app_name_max_length = 60;
config.app_title_max_length = 60;
config.min_pass_length = 6;
config.strict_email_verification_required = false;
config.require_email_verification_to_publish_website = false;
config.kv_max_key_size = 1024;
config.kv_max_value_size = 400 * 1024;
// Captcha configuration
config.captcha = {
enabled: false, // Enable captcha by default
expirationTime: 10 * 60 * 1000, // 10 minutes default expiration time
difficulty: 'medium', // Default difficulty level
};
// OIDC/OAuth2 providers (e.g. Google). Keys in config only, not env vars.
// Example: config.oidc.providers.google = { client_id, client_secret }
config.oidc = {
providers: {},
};
config.monitor = {
metricsInterval: 60000,
windowSize: 30,
};
config.max_subdomains_per_user = 2000;
config.storage_capacity = 1 * 1024 * 1024 * 1024;
config.static_hosting_base_domain_redirect = 'https://developer.puter.com/static-hosting/';
config.enable_private_app_access_gate = true;
// Storage limiting is set to false by default
// Storage available on the mountpoint/drive puter is running is the storage available
config.is_storage_limited = false;
config.available_device_storage = null;
config.thumb_width = 80;
config.thumb_height = 80;
config.app_max_icon_size = 5 * 1024 * 1024;
config.defaultjs_asset_path = '../../';
config.short_description = 'Puter is a privacy-first personal cloud that houses all your files, apps, and games in one private and secure place, accessible from anywhere at any time.';
config.title = 'Puter';
config.company = 'Puter Technologies Inc.';
config.puter_hosted_data = {
puter_versions: 'https://version.puter.site/puter_versions.json',
};
{
const path_ = require('path');
config.assets = {
gui: path_.join(__dirname, '../../gui'),
gui_profile: 'development',
};
}
// words that cannot be used by others as subdomains or app names
// config.reserved_words = reserved_words;
config.reserved_words = [];
{
config.reserved_words.push(...require('./config/reserved_words'));
}
// set default S3 settings for this server, if any
if ( config.server_id ) {
// see if this server has a specific bucket
for ( const server of config.servers ) {
if ( server.id !== config.server_id ) continue;
if ( ! server.s3_bucket ) continue;
config.s3_bucket = server.s3_bucket;
config.s3_region = server.region;
}
}
config.contact_email = `hey@${ config.domain}`;
// TODO: default value will be changed to false in a future release;
// details to follow in a future announcement.
config.legacy_token_migrate = true;
// === OS Information ===
const os = require('os');
const fs = require('fs');
const { Context, context_config } = require('./util/context');
config.os = {};
config.os.platform = os.platform();
if ( config.os.platform === 'linux' ) {
try {
const osRelease = fs.readFileSync('/etc/os-release').toString();
// CONTRIBUTORS: If this is the behavior you expect, please add your
// Linux distro here.
if ( osRelease.includes('ID=arch') ) {
config.os.distro = 'arch';
config.os.archbtw = true;
}
} catch (_) {
// We don't care if we can't read this file;
// we'll just assume it's not a Linux distro.
}
}
// config.os.refined specifies if Puter is running within a host environment
// where a higher level of user configuration and control is expected.
config.os.refined = config.os.archbtw;
if ( config.os.refined ) {
config.no_browser_launch = true;
}
// NEW_CONFIG_LOADING
const maybe_port = config =>
config.pub_port !== 80 && config.pub_port !== 443 ? `:${ config.pub_port}` : '';
const computed_defaults = {
pub_port: config => config.http_port,
origin: config => `${config.protocol }://${ config.domain }${maybe_port(config)}`,
api_base_url: config => config.experimental_no_subdomain
? config.origin
: `${config.protocol }://api.${ config.domain }${maybe_port(config)}`,
social_card: config => `${config.origin}/assets/img/screenshot.png`,
static_hosting_domain: config => `site.${ config.domain }${ maybe_port(config)}`,
// Hostname-only fallback helps host matching code paths that compare against req.hostname.
static_hosting_domain_alt: (config) => `site.${ config.domain }`,
private_app_hosting_domain: config => `app.${ config.domain }${ maybe_port(config)}`,
private_app_hosting_domain_alt: () => `app.${ config.domain }`, // Hostname-only fallback helps host matching code paths that compare against req.hostname.
};
// We're going to export a config object that's decorated
// with additional behavior
let config_to_export;
// We have a pointer to some config object which
// load_config() may replace
const config_pointer = {};
{
Object.setPrototypeOf(config_pointer, config);
config_to_export = config_pointer;
}
// We have some methods that can be called on `config`
{
// Add configuration values with precedence over the current config
const load_config = o => {
let replacement_config = {
...o,
};
replacement_config = deep_proto_merge(replacement_config, Object.getPrototypeOf(config_pointer), {
preserve_flag: true,
});
Object.setPrototypeOf(config_pointer, replacement_config);
};
const config_api = { load_config };
Object.setPrototypeOf(config_api, config_to_export);
config_to_export = config_api;
}
// We have some values with computed defaults
{
const get_implied = (target, prop) => {
if ( prop in computed_defaults ) {
return computed_defaults[prop](target);
}
return undefined;
};
config_to_export = new Proxy(config_to_export, {
get: (target, prop, _receiver) => {
if ( prop in target ) {
return target[prop];
} else {
return get_implied(config_to_export, prop);
}
},
});
}
// We'd like to store values changed at runtime separately
// for easier runtime debugging
{
const config_runtime_values = {
$: 'runtime-values',
};
let initialPrototype = config_to_export;
Object.setPrototypeOf(config_runtime_values, config_to_export);
config_to_export = config_runtime_values;
config_to_export.__set_config_object__ = (object, options = {}) => {
// options for this method
const replacePrototype = options.replacePrototype ?? true;
const useInitialPrototype = options.useInitialPrototype ?? true;
// maybe replace prototype
if ( replacePrototype ) {
const newProto = useInitialPrototype
? initialPrototype
: Object.getPrototypeOf(config_runtime_values);
Object.setPrototypeOf(object, newProto);
}
// use this object as the prototype
Object.setPrototypeOf(config_runtime_values, object);
};
// These can be difficult to find and cause painful
// confusing issues, so we log any time this happens
config_to_export = new Proxy(config_to_export, {
set: (target, prop, value, _receiver) => {
const logger = Context.get('logger', { allow_fallback: true });
// If no logger, just give up
if ( logger ) {
logger.debug(
'\x1B[36;1mCONFIGURATION MUTATED AT RUNTIME\x1B[0m',
{ prop, value },
);
}
target[prop] = value;
return true;
},
});
}
// We configure the behavior in context.js from here to avoid a cyclic
// mutual dependency between it and this file.
//
// Previously we had this:
// context --(are we in "dev" environment?)--> config
//
// So we could not add this:
// config --(where is the logger?) --> context
//
// So instead we now have:
// config --(read this property to determine 'strict' mode)--> context
// config --(where is the logger?) --> context
//
Object.defineProperty(context_config, 'strict', {
get: () => config_to_export.env === 'dev',
configurable: true,
});
module.exports = config_to_export;
================================================
FILE: src/backend/src/consts/app-icons.js
================================================
export const APP_ICONS_SUBDOMAIN = 'puter-app-icons';
================================================
FILE: src/backend/src/data/hardcoded-permissions.js
================================================
/*
* 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 .
*/
const default_implicit_user_app_permissions = {
'driver:helloworld:greet': {},
'driver:puter-kvstore': {},
'driver:puter-ocr:recognize': {},
'driver:puter-chat-completion': {},
'driver:puter-image-generation': {},
'driver:puter-video-generation': {},
'driver:puter-tts': {},
'driver:puter-speech2speech': {},
'driver:puter-speech2txt': {},
'driver:puter-apps': {},
'driver:puter-subdomains': {},
'driver:temp-email': {},
'service': {},
'feature': {},
};
const implicit_user_app_permissions = [
{
id: 'builtin-apps',
apps: [
'app-0bef044f-918f-4cbf-a0c0-b4a17ee81085', // about
'app-838dfbc4-bf8b-48c2-b47b-c4adc77fab58', // editor
'app-58282b08-990a-4906-95f7-fa37ff92452b', // draw
'app-5584fbf7-ed69-41fc-99cd-85da21b1ef51', // camera
'app-7bdca1a4-6373-4c98-ad97-03ff2d608ca1', // recorder
'app-240a43f4-43b1-49bc-b9fc-c8ae719dab77', // dev-center
'app-a2ae72a4-1ba3-4a29-b5c0-6de1be5cf178', // app-center
'app-74378e84-b9cd-5910-bcb1-3c50fa96d6e7', // https://nj.puter.site
'app-13a38aeb-f9f6-54f0-9bd3-9d4dd655ccfe', // https://cdpn.io
'app-dce8f797-82b0-5d95-a2f8-ebe4d71b9c54', // https://null.jsbin.com
'app-93005ce0-80d1-50d9-9b1e-9c453c375d56', // https://markus.puter.com
],
permissions: {
'driver:helloworld:greet': {},
'driver:puter-ocr:recognize': {},
'driver:puter-kvstore:get': {},
'driver:puter-kvstore:set': {},
'driver:puter-kvstore:del': {},
'driver:puter-kvstore:list': {},
'driver:puter-kvstore:flush': {},
'driver:puter-chat-completion:complete': {},
'driver:puter-image-generation:generate': {},
'driver:puter-video-generation:generate': {},
'driver:puter-speech2speech:convert': {},
'driver:puter-speech2txt:transcribe': {},
'driver:puter-speech2txt:translate': {},
'driver:puter-analytics:create_trace': {},
'driver:puter-analytics:record': {},
},
},
{
id: 'local-testing',
apps: [
'app-a392f3e5-35ca-5dac-ae10-785696cc7dec', // https://localhost
'app-a6263561-6a84-5d52-9891-02956f9fac65', // https://127.0.0.1
'app-26149f0b-8304-5228-b995-772dadcf410e', // http://localhost
'app-c2e27728-66d9-54dd-87cd-6f4e9b92e3e3', // http://127.0.0.1
],
permissions: {
'driver:helloworld:greet': {},
'driver:puter-ocr:recognize': {},
'driver:puter-kvstore:get': {},
'driver:puter-kvstore:set': {},
'driver:puter-kvstore:del': {},
'driver:puter-kvstore:list': {},
'driver:puter-kvstore:flush': {},
},
},
];
const driverPolicies = {
temp: {
kv: {
'rate-limit': {
max: 1000,
period: 30000,
},
},
es: {
'rate-limit': {
max: 1000,
period: 30000,
},
},
},
user: {
kv: {
'rate-limit': {
max: 3000,
period: 30000,
},
},
es: {
'rate-limit': {
max: 3000,
period: 30000,
},
},
},
};
const clonePolicy = policy =>
JSON.parse(JSON.stringify(policy));
const getPolicyBySelector = selector => {
const [scope, policyName] = selector.split('.');
const policy = driverPolicies[scope]?.[policyName];
if ( ! policy ) {
throw new Error(`unknown driver policy selector: ${selector}`);
}
return policy;
};
const policyPerm = selector => ({
policy: {
...clonePolicy(getPolicyBySelector(selector)),
},
});
const hardcoded_user_group_permissions = {
system: {
'ca342a5e-b13d-4dee-9048-58b11a57cc55': {
'driver': {},
'service': {},
'feature': {},
'kernel-info': {},
'local-terminal:access': {},
},
'b7220104-7905-4985-b996-649fdcdb3c8f': {
'driver': {},
'service': {},
'service:hello-world:ii:hello-world': policyPerm('temp.es'),
'service:puter-kvstore:ii:puter-kvstore': policyPerm('temp.kv'),
'driver:puter-kvstore': policyPerm('temp.kv'),
'service:puter-notifications:ii:crud-q': policyPerm('temp.es'),
'service:puter-apps:ii:crud-q': policyPerm('temp.es'),
'service:puter-subdomains:ii:crud-q': policyPerm('temp.es'),
'service:apps:ii:crud-q': policyPerm('temp.es'),
'service:es\\Cnotification:ii:crud-q': policyPerm('user.es'),
'service:es\\Capp:ii:crud-q': policyPerm('user.es'),
'service:app:ii:crud-q': policyPerm('user.es'),
'service:es\\Csubdomain:ii:crud-q': policyPerm('user.es'),
},
'78b1b1dd-c959-44d2-b02c-8735671f9997': {
'driver': {},
'service': {},
'service:hello-world:ii:hello-world': policyPerm('user.es'),
'service:puter-kvstore:ii:puter-kvstore': policyPerm('user.kv'),
'driver:puter-kvstore': policyPerm('user.kv'),
'service:es\\Cnotification:ii:crud-q': policyPerm('user.es'),
'service:es\\Capp:ii:crud-q': policyPerm('user.es'),
'service:app:ii:crud-q': policyPerm('user.es'),
'service:es\\Csubdomain:ii:crud-q': policyPerm('user.es'),
'service:apps:ii:crud-q': policyPerm('user.es'),
},
},
};
module.exports = {
implicit_user_app_permissions,
default_implicit_user_app_permissions,
hardcoded_user_group_permissions,
};
================================================
FILE: src/backend/src/definitions/SimpleEntity.js
================================================
/*
* 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 .
*/
const { Context } = require('../util/context');
module.exports = function SimpleEntity ({ name, methods, fetchers }) {
const create = function (values) {
const entity = { values };
Object.assign(entity, methods);
for ( const fetcher_name in fetchers ) {
entity[`fetch_${ fetcher_name}`] = async function () {
if ( Object.prototype.hasOwnProperty.call(this.values, fetcher_name) ) {
return this.values[fetcher_name];
}
const value = await fetchers[fetcher_name].call(this);
this.values[fetcher_name] = value;
return value;
};
}
entity.context = values.context ?? Context.get();
entity.services = entity.context.get('services');
return entity;
};
create.name = name;
return create;
};
================================================
FILE: src/backend/src/entities/Group.js
================================================
/*
* 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 .
*/
const SimpleEntity = require('../definitions/SimpleEntity');
module.exports = SimpleEntity({
name: 'group',
fetchers: {
async members () {
const svc_group = this.services.get('group');
const members = await svc_group.list_members({ uid: this.values.uid });
return members;
},
},
methods: {
async get_client_value (options = {}) {
if ( options.members ) {
await this.fetch_members();
}
const group = {
uid: this.values.uid,
metadata: this.values.metadata,
...(options.members ? { members: this.values.members } : {}),
};
return group;
},
},
});
================================================
FILE: src/backend/src/env
================================================
dev
================================================
FILE: src/backend/src/errors/TechnicalError.js
================================================
/*
* 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 .
*/
/**
* @class TechnicalError
* @extends Error
*
* This error type is used for errors that may be presented in a
* technical context, such as a terminal or log file.
*
* @todo This could be a trait errors can have rather than a class.
*/
class TechnicalError extends Error {
constructor (message, ...details) {
super(message);
for ( const detail of details ) {
detail(this);
}
}
}
const ERR_HINT_NOSTACK = e => {
e.toString = () => e.message;
};
module.exports = {
TechnicalError,
ERR_HINT_NOSTACK,
};
================================================
FILE: src/backend/src/errors/error_help_details.js
================================================
/*
* 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 .
*/
const { quot } = require('@heyputer/putility').libs.string;
const reused = {
runtime_env_references: [
{
subject: 'ENVIRONMENT.md file',
location: 'root of the repository',
use: 'describes which paths are checked',
},
{
subject: 'boot logger',
location: 'above this text',
use: 'shows what checks were performed',
},
{
subject: 'RuntimeEnvironment.js',
location: 'src/boot/ in repository',
use: 'code that performs the checks',
},
],
};
const programmer_errors = [
'Assignment to constant variable.',
];
const error_help_details = [
{
match: ({ message }) => (
message.startsWith('No suitable path found for')
),
apply (more) {
more.references = [
...reused.runtime_env_references,
];
},
},
{
match: ({ message }) => (
message.match(/^No (read|write) permission for/)
),
apply (more) {
more.solutions = [
{
title: 'Change permissions with chmod',
},
{
title: 'Remove the path to use working directory',
},
{
title: 'Set CONFIG_PATH or RUNTIME_PATH environment variable',
},
];
more.references = [
...reused.runtime_env_references,
];
},
},
{
match: ({ message }) => (
message.startsWith('No valid config file found in path')
),
apply (more) {
more.solutions = [
{
title: 'Create a valid config file',
},
];
},
},
{
match: ({ message }) => (
message === 'config_name is required'
),
apply (more) {
more.solutions = [
'ensure config_name is present in your config file',
'Seek help on https://discord.gg/PQcx7Teh8u (our Discord server)',
];
},
},
{
match: ({ message }) => (
message == 'Assignment to constant variable.'
),
apply (more) {
more.references = [
{
subject: 'MDN Reference for this error',
location: 'on the internet',
use: 'describes why this error occurs',
url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Invalid_const_assignment',
},
];
},
},
{
match: ({ message }) => (
programmer_errors.includes(message)
),
apply (more) {
more.notes = [
'It looks like this might be our fault.',
];
more.solutions = [
{ title: 'Check for an issue on https://github.com/HeyPuter/puter/issues' },
{ title: 'If there is no issue, please create one: https://github.com/HeyPuter/puter/issues/new' },
];
},
},
{
match: ({ message }) => (
message.startsWith('Expected double-quoted property')
),
apply (more) {
more.notes = [
'There might be a trailing-comma in your config',
];
},
},
];
/**
* Print error help information to a stream in a human-readable format.
*
* @param {Error} err - The error to print help for.
* @param {*} out - The stream to print to; defaults to process.stdout.
* @returns {undefined}
*/
const print_error_help = (err, out = process.stdout) => {
if ( ! err.more ) {
err.more = {};
err.more.references = [];
err.more.solutions = [];
for ( const detail of error_help_details ) {
if ( detail.match(err) ) {
detail.apply(err.more);
}
}
}
let write = out.write.bind(out);
write('\n');
const wrap_msg = s =>
`\x1B[31;1m┏━━ [ HELP:\x1B[0m ${quot(s)} \x1B[31;1m]\x1B[0m`;
const wrap_list_title = s =>
`\x1B[36;1m${s}:\x1B[0m`;
write(`${wrap_msg(err.message) }\n`);
write = (s) => out.write(`\x1B[31;1m┃\x1B[0m ${ s}`);
const vis = (stok, etok, str) => {
return `\x1B[36;1m${stok}\x1B[0m${str}\x1B[36;1m${etok}\x1B[0m`;
};
let lf_sep = false;
write('Whoops! Looks like something isn\'t working!\n');
let any_help = false;
if ( err.more.notes ) {
write('\n');
lf_sep = true;
any_help = true;
for ( const note of err.more.notes ) {
write(`\x1B[33;1m * ${note}\x1B[0m\n`);
}
}
if ( err.more.solutions?.length > 0 ) {
if ( lf_sep ) write('\n');
lf_sep = true;
any_help = true;
write('The suggestions below may help resolve this issue.\n');
write('\n');
write(`${wrap_list_title('Possible Solutions') }\n`);
for ( const sol of err.more.solutions ) {
write(` - ${sol.title}\n`);
}
}
if ( err.more.references?.length > 0 ) {
if ( lf_sep ) write('\n');
lf_sep = true;
any_help = true;
write('The references below may be related to this issue.\n');
write('\n');
write(`${wrap_list_title('References') }\n`);
for ( const ref of err.more.references ) {
write(` - ${vis('[', ']', ref.subject)} ` +
`${vis('(', ')', ref.location)};\n`);
write(` ${ref.use}\n`);
if ( ref.url ) {
write(` ${ref.url}\n`);
}
}
}
if ( ! any_help ) {
write('No help is available for this error.\n');
write('Help can be added in src/errors/error_help_details.\n');
}
out.write('\x1B[31;1m┗━━ [ END HELP ]\x1B[0m\n');
out.write('\n');
};
module.exports = {
error_help_details,
print_error_help,
};
================================================
FILE: src/backend/src/extension/RuntimeModule.js
================================================
const { AdvancedBase } = require('@heyputer/putility');
class RuntimeModule extends AdvancedBase {
constructor (options = {}) {
super();
this.exports_ = undefined;
this.exports_is_set_ = false;
this.remappings = options.remappings ?? {};
this.name = options.name ?? undefined;
}
set exports (value) {
this.exports_is_set_ = true;
this.exports_ = value;
}
get exports () {
if ( this.exports_is_set_ === false && this.defer ) {
this.exports = this.defer();
}
return this.exports_;
}
import (name) {
if ( Object.prototype.hasOwnProperty.call(this.remappings, name) ) {
name = this.remappings[name];
}
return this.runtimeModuleRegistry.exportsOf(name);
}
}
module.exports = { RuntimeModule };
================================================
FILE: src/backend/src/extension/RuntimeModuleRegistry.js
================================================
const { AdvancedBase } = require('@heyputer/putility');
const { RuntimeModule } = require('./RuntimeModule');
class RuntimeModuleRegistry extends AdvancedBase {
constructor () {
super();
this.modules_ = {};
}
register (extensionModule, options = {}) {
if ( ! (extensionModule instanceof RuntimeModule) ) {
throw new Error(`expected a RuntimeModule, but got: ${
extensionModule?.constructor?.name ?? typeof extensionModule})`);
}
const uniqueName = options.as ?? extensionModule.name ?? require('uuid').v4();
if ( this.modules_.hasOwnProperty(uniqueName) ) {
throw new Error(`duplicate runtime module: ${uniqueName}`);
}
this.modules_[uniqueName] = extensionModule;
extensionModule.runtimeModuleRegistry = this;
}
exportsOf (name) {
if ( ! this.modules_[name] ) {
throw new Error(`could not find runtime module: ${name}`);
}
return this.modules_[name].exports;
}
}
module.exports = {
RuntimeModuleRegistry,
};
================================================
FILE: src/backend/src/filesystem/ECMAP.js
================================================
const { Context } = require('../util/context');
const { NodeUIDSelector, NodePathSelector, NodeInternalIDSelector } = require('./node/selectors');
const LOG_PREFIX = '\x1B[31;1m[[\x1B[33;1mEC\x1B[32;1mMAP\x1B[31;1m]]\x1B[0m';
/**
* The ECMAP class is a memoization structure used by FSNodeContext
* whenever it is present in the execution context (AsyncLocalStorage).
* It is assumed that this object is transient and invalidation of stale
* entries is not necessary.
*
* The name ECMAP simple means Execution Context Map, because the map
* exists in memory at a particular frame of the execution context.
*/
class ECMAP {
static SYMBOL = Symbol('ECMAP');
constructor () {
this.identifier = require('uuid').v4();
// entry caches
this.uuid_to_fsNodeContext = {};
this.path_to_fsNodeContext = {};
this.id_to_fsNodeContext = {};
// identifier association caches
this.path_to_uuid = {};
this.uuid_to_path = {};
this.unlinked = false;
}
/**
* unlink() clears all references from this ECMAP to ensure that it will be
* GC'd. This is called by ECMAP.arun() after the callback has resolved.
*/
unlink () {
this.unlinked = true;
this.uuid_to_fsNodeContext = null;
this.path_to_fsNodeContext = null;
this.id_to_fsNodeContext = null;
this.path_to_uuid = null;
this.uuid_to_path = null;
}
get logPrefix () {
return `${LOG_PREFIX} \x1B[36[1m${this.identifier}\x1B[0m`;
}
log (...a) {
if ( ! process.env.LOG_ECMAP ) return;
console.log(this.logPrefix, ...a);
}
get_fsNodeContext_from_selector (selector) {
if ( this.unlinked ) return null;
this.log('GET', selector.describe());
const retvalue = (() => {
let value;
if ( selector instanceof NodeUIDSelector ) {
value = this.uuid_to_fsNodeContext[selector.value];
if ( value ) return value;
let maybe_path = this.uuid_to_path[value];
if ( ! maybe_path ) return;
value = this.path_to_fsNodeContext[maybe_path];
if ( value ) return value;
}
else
if ( selector instanceof NodePathSelector ) {
value = this.path_to_fsNodeContext[selector.value];
if ( value ) return value;
let maybe_uid = this.path_to_uuid[value];
value = this.uuid_to_fsNodeContext[maybe_uid];
if ( value ) return value;
}
})();
if ( retvalue ) {
this.log('\x1B[32;1m <<<<< ECMAP HIT >>>>> \x1B[0m');
} else {
this.log('\x1B[31;1m <<<<< ECMAP MISS >>>>> \x1B[0m');
}
return retvalue;
}
store_fsNodeContext_to_selector (selector, node) {
if ( this.unlinked ) return null;
this.log('STORE', selector.describe());
if ( selector instanceof NodeUIDSelector ) {
this.uuid_to_fsNodeContext[selector.value] = node;
}
if ( selector instanceof NodePathSelector ) {
this.path_to_fsNodeContext[selector.value] = node;
}
if ( selector instanceof NodeInternalIDSelector ) {
this.id_to_fsNodeContext[`${selector.service}:${selector.id}`] = node;
}
}
store_fsNodeContext (node) {
if ( this.unlinked ) return;
this.store_fsNodeContext_to_selector(node.selector, node);
}
static async arun (cb) {
let context = Context.get();
if ( ! context.get(this.SYMBOL) ) {
const ins = new this();
context = context.sub({
[this.SYMBOL]: ins,
});
const result = await context.arun(cb);
ins.unlink();
context.unlink();
return result;
}
return await cb();
}
}
module.exports = { ECMAP };
================================================
FILE: src/backend/src/filesystem/FSNodeContext.js
================================================
/*
* 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 .
*/
const { get_user, id2path, id2uuid, is_empty, suggestedAppForFsEntry, get_app } = require('../helpers');
const putility = require('@heyputer/putility');
const config = require('../config');
const _path = require('path');
const { NodeInternalIDSelector, NodeChildSelector, NodeUIDSelector, RootNodeSelector, NodePathSelector } = require('./node/selectors');
const { Context } = require('../util/context');
const { getTracer, span } = require('../util/otelutil');
const { NodeRawEntrySelector } = require('./node/selectors');
const { DB_READ } = require('../services/database/consts');
const { UserActorType, AppUnderUserActorType, Actor } = require('../services/auth/Actor');
const { PermissionUtil } = require('../services/auth/permissionUtils.mjs');
const { ECMAP } = require('./ECMAP');
const { MANAGE_PERM_PREFIX } = require('../services/auth/permissionConts.mjs');
/**
* 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
*/
const TYPE_FILE = { label: 'File' };
const TYPE_DIRECTORY = { label: 'Directory' };
module.exports = class FSNodeContext {
static CONCERN = 'filesystem';
static TYPE_FILE = TYPE_FILE;
static TYPE_DIRECTORY = TYPE_DIRECTORY;
static TYPE_SYMLINK = {};
static TYPE_SHORTCUT = {};
static TYPE_UNDETERMINED = {};
static SELECTOR_PRIORITY_ORDER = [
NodeRawEntrySelector,
RootNodeSelector,
NodeInternalIDSelector,
NodeUIDSelector,
NodeChildSelector,
NodePathSelector,
];
#writable;
/**
* 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,
provider,
fs,
}) {
const ecmap = Context.get(ECMAP.SYMBOL);
if ( ecmap && !(selector instanceof NodeRawEntrySelector) ) {
// We might return an existing FSNodeContext
const maybe_node = ecmap
?.get_fsNodeContext_from_selector?.(selector);
if ( maybe_node ) return maybe_node;
} else {
if ( process.env.LOG_ECMAP ) {
console.log('\x1B[31;1m !!! NO ECMAP !!! \x1B[0m');
}
}
// This will be used to avoid concurrent fetches. Whenever an entry is being fetched,
// a subsequent call to fetchEntry must await this promise. Usually this means the
// subsequent call will not perform any expensive operations.
this.fetching = null;
this.log = services.get('log-service').create('fsnode-context', {
concern: this.constructor.CONCERN,
});
this.selector_ = null;
this.selectors_ = [];
this.selector = selector;
this.provider = provider;
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: Apply method decorators using a putility class feature
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 = getTracer();
let result;
const opts = { attributes: {
selector: selector.describe(),
trace: (new Error()).stack,
} };
await tracer.startActiveSpan(`fs:nodectx:fetch:${method}`, opts, 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;
}
const ecmap = Context.get(ECMAP.SYMBOL);
if ( ecmap ) {
ecmap.store_fsNodeContext_to_selector(new_selector, this);
}
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 isAppDataDirectory () {
if ( this.isRoot ) return false;
if ( this.found === undefined ) {
await this.fetchEntry();
}
if ( this.isRoot ) return false;
const components = await this.getPathComponents();
if ( components.length < 2 ) return false;
return components[1] === 'AppData';
}
async isPublic () {
if ( this.isRoot ) return false;
const components = await this.getPathComponents();
if ( await this.isUserDirectory() ) return false;
if ( components[1] === 'Public' ) return true;
return false;
}
async getPathComponents () {
if ( this.isRoot ) return [];
// We can get path components for non-existing nodes if they
// have a path selector
if ( ! await this.exists() ) {
if ( this.selector instanceof NodePathSelector ) {
let path = this.selector.value;
if ( path.startsWith('/') ) path = path.slice(1);
return path.split('/');
}
// TODO: add support for NodeChildSelector as well
}
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 } = {}) {
if ( this.found !== undefined ) {
return this.found;
}
await this.fetchEntry(fetch_options);
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;
if ( this.entry?.path ) {
this.path = this.entry.path;
return;
}
const uid = this.entry?.uuid ?? this.uid;
if ( ! uid ) return;
this.path = await this.#resolvePathFromUuid(uid);
}
async #resolvePathFromUuid (uuid) {
if ( ! uuid ) return undefined;
try {
return await id2path(uuid);
} catch (e) {
return `/-void/${ uuid }`;
}
}
/**
* 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.fetching !== null ) {
await span('fetching', async () => {
// ???: does this need to be double-checked? I'm not actually sure...
if ( this.fetching === null ) return;
await this.fetching;
});
}
this.fetching = new putility.libs.promise.TeePromise();
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
)
) {
const promise = this.fetching;
this.fetching = null;
promise.resolve();
return;
}
const controls = {
log: this.log,
provide_selector: selector => {
this.selector = selector;
},
};
this.log.debug(`fetching entry: ${ this.selector.describe()}`);
const entry = await this.provider.stat({
selector: this.selector,
options: fetch_entry_options,
node: this,
controls,
});
if ( ! entry ) {
this.found = false;
this.entry = false;
} else {
this.found = true;
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);
}
const promise = this.fetching;
this.fetching = null;
promise.resolve();
}
/**
* 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) {
const db = this.services.get('database').get(DB_READ, 'filesystem');
this.entry.subdomains = [];
this.entry.workers = [];
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.applySingleSubdomain(sd);
});
this.entry.has_website = true;
}
}
applySingleSubdomain (sd) {
if ( this.entry.is_dir ) {
this.entry.subdomains.push({
subdomain: sd.subdomain,
address: `${config.protocol }://${ sd.subdomain }.` + 'puter.site',
uuid: sd.uuid,
});
} else {
const workerName = sd.subdomain.split('.').pop();
this.entry.workers.push({
subdomain: workerName,
address: `https://${ workerName }.` + 'puter.work',
uuid: sd.uuid,
});
}
}
/**
* 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 fsPermPrefix = `fs:${await this.get('uid')}`;
const [readWritePerms, managePerms] = await Promise.all([
svc_permission.query_issuer_permissions_by_prefix(actor.type.user, `${fsPermPrefix}:`),
svc_permission.query_issuer_permissions_by_prefix(actor.type.user, `${MANAGE_PERM_PREFIX}:${fsPermPrefix}`),
]);
this.entry.shares = { users: [], apps: [] };
for ( const readWriteUserPerms of readWritePerms.users ) {
const access =
PermissionUtil.split(readWriteUserPerms.permission).slice(-1)[0];
this.entry.shares.users.push({
user: {
uid: readWriteUserPerms.user.uuid,
username: readWriteUserPerms.user.username,
},
access,
permission: readWriteUserPerms.permission,
});
}
for ( const manageUserPerms of managePerms.users ) {
const access = MANAGE_PERM_PREFIX;
this.entry.shares.users.push({
user: {
uid: manageUserPerms.user.uuid,
username: manageUserPerms.user.username,
},
access,
permission: manageUserPerms.permission,
});
}
for ( const readWriteAppPerms of readWritePerms.apps ) {
const access =
PermissionUtil.split(readWriteAppPerms.permission).slice(-1)[0];
this.entry.shares.apps.push({
app: {
icon: readWriteAppPerms.app.icon,
uid: readWriteAppPerms.app.uid,
name: readWriteAppPerms.app.name,
},
access,
permission: readWriteAppPerms.permission,
});
}
for ( const manageAppPerms of readWritePerms.apps ) {
const access =
MANAGE_PERM_PREFIX;
this.entry.shares.apps.push({
app: {
icon: manageAppPerms.app.icon,
uid: manageAppPerms.app.uid,
name: manageAppPerms.app.name,
},
access,
permission: manageAppPerms.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 ( const version of versions ) {
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.
*/
async fetchSize () {
// we already have the size for files
if ( ! this.entry.is_dir ) {
await this.fetchEntry();
return this.entry.size;
}
this.entry.size = await this.provider.get_recursive_size({ node: this });
return this.entry.size;
}
/** Avoid using if fetching directory items */
async fetchSuggestedApps (user, force) {
if ( this.entry.suggested_apps && !force ) return;
await this.fetchEntry();
if ( ! this.entry ) return;
this.entry.suggested_apps =
await suggestedAppForFsEntry(this.entry, { user });
}
async fetchIsEmpty () {
if ( !this.uid && !this.path ) return;
this.entry && (this.entry.is_empty = await is_empty({
uid: this.uid,
path: this.path,
}));
}
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, force) {
/*
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 ) {
await this.fetchPath();
}
if ( ! this.path ) {
throw new Error('failed to get path');
}
return this.path;
}
if ( key === 'uid' ) {
const uidSelector = this.get_selector_of_type(NodeUIDSelector);
if ( uidSelector ) {
return uidSelector.value;
}
await this.fetchEntry();
return this.uid;
}
if ( key === 'mysql-id' ) {
await this.fetchEntry();
return this.mysql_id ?? this.entry.id;
}
if ( key === 'owner' ) {
const user_id = await this.get('user_id');
const actor = new Actor({
type: new UserActorType({
user: await get_user({ id: user_id }),
}),
});
return actor;
}
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 null for local filesystem
if ( ! this.entry.bucket ) {
return null;
}
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;
}
if ( key === 'writable' ) {
if ( this.#writable && !force ) return this.#writable;
const actor = Context.get('actor');
if ( !actor || !actor.type.user ) return undefined;
const svc_acl = this.services.get('acl');
return this.#writable = await svc_acl.check(actor, this, 'write');
}
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 hasChild (name) {
return await this.provider.directory_has_name({ parent: this, 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;
}
if ( this.path && fsNode.path ) {
return await this.get('path') === await fsNode.get('path');
}
await this.fetchEntry();
await fsNode.fetchEntry();
return this.uid === fsNode.uid;
}
async getSafeEntry (fetch_options = {}) {
const svc_event = this.services.get('event');
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 = {};
if ( res.thumbnail ) {
await svc_event.emit('thumbnail.read', this.entry);
}
// 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];
}
let actor; try {
actor = Context.get('actor');
} catch ( _e ) {
// fail silently
}
if ( !actor?.type?.user || actor.type.user.id !== res.user_id ) {
if ( ! fsentry.owner ) await this.fetchOwner();
fsentry.owner = {
username: res.owner?.username,
};
}
if ( ! ( actor.type === AppUnderUserActorType ) ) {
if ( fsentry.owner ) delete fsentry.owner.email;
}
if ( !this.uid && !this.entry.uuid ) {
console.warn(`Potential Error in getSafeEntry with no uid or entry.uuid ${
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.
const entry_uid = this.uid ?? this.entry.uuid;
fsentry.path = res.path ?? this.path ?? await this.#resolvePathFromUuid(entry_uid);
if ( fsentry.path && fsentry.path.startsWith('/-void/') ) {
fsentry.broken = true;
}
fsentry.dirname = _path.dirname(fsentry.path);
fsentry.dirpath = fsentry.dirname;
fsentry.writable = await this.get('writable');
// 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);
try {
fsentry.shortcut_to_path = (res.shortcut_to
? await id2path(res.shortcut_to) : undefined);
} catch ( _e ) {
fsentry.shortcut_invalid = true;
fsentry.shortcut_uid = res.shortcut_to;
}
// 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 ) {
if ( res.associated_app ) {
fsentry.associated_app = res.associated_app;
} else {
const app = await get_app({ id: fsentry.associated_app_id });
fsentry.associated_app = app;
}
}
// If this file is in an appdata directory, add `appdata_app`
const components = await this.getPathComponents();
if ( components[1] === 'AppData' ) {
fsentry.appdata_app = components[2];
}
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;
}
};
module.exports.TYPE_FILE = TYPE_FILE;
module.exports.TYPE_DIRECTORY = TYPE_DIRECTORY;
================================================
FILE: src/backend/src/filesystem/FilesystemService.js
================================================
/*
* 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 .
*/
// TODO: database access can be a service
const { RESOURCE_STATUS_PENDING_CREATE } = require('../modules/puterfs/ResourceService.js');
const { NodePathSelector, NodeUIDSelector, NodeInternalIDSelector, NodeSelector } = require('./node/selectors.js');
const FSNodeContext = require('./FSNodeContext.js');
const { Context } = require('../util/context.js');
const APIError = require('../api/APIError.js');
const { PermissionUtil, PermissionRewriter, PermissionImplicator, PermissionExploder } = require('../services/auth/permissionUtils.mjs');
const { DB_WRITE } = require('../services/database/consts');
const { UserActorType } = require('../services/auth/Actor');
const { get_user } = require('../helpers');
const BaseService = require('../services/BaseService');
const { MANAGE_PERM_PREFIX } = require('../services/auth/permissionConts.mjs');
const { quot } = require('@heyputer/putility/src/libs/string.js');
const fsCapabilities = require('./definitions/capabilities.js');
class FilesystemService extends BaseService {
static MODULES = {
_path: require('path'),
uuidv4: require('uuid').v4,
config: require('../config.js'),
};
old_constructor (args) {
const { services } = args;
// The new fs entry service
this.log = services.get('log-service').create('filesystem-service');
// used by update_child_paths
this.db = services.get('database').get(DB_WRITE, 'filesystem');
}
async _init () {
this.old_constructor({ services: this.services });
const svc_permission = this.services.get('permission');
svc_permission.register_rewriter(PermissionRewriter.create({
matcher: permission => {
if ( !permission.startsWith('fs:') && !permission.startsWith('manage:fs:') ) return false;
const [_, specifier] = permission.split('fs:');
if ( ! specifier.startsWith('/') ) return false;
return true;
},
rewriter: async permission => {
const [manageOpt, pathPerm] = permission.split('fs:');
const [path, ...rest] = PermissionUtil.split(pathPerm);
const node = await this.node(new NodePathSelector(path));
if ( ! await node.exists() ) {
// TOOD: we need a general-purpose error that can have
// a user-safe message, instead of using APIError
// which is for API errors.
throw APIError.create('subject_does_not_exist');
}
const uid = await node.get('uid');
if ( uid === undefined || uid === 'undefined' ) {
throw new Error(`uid is undefined for path ${path}`);
}
return [manageOpt.replace(':', ''), 'fs', uid, ...rest].filter(Boolean).join(':');
},
}));
svc_permission.register_implicator(PermissionImplicator.create({
id: 'is-owner',
shortcut: true,
matcher: permission => {
// TODO DS: for now users will only have manage access on files, that might change, and then this has to change too
return permission.startsWith('fs:')
|| permission.startsWith(`${MANAGE_PERM_PREFIX}:fs:`)
|| permission.startsWith(`${MANAGE_PERM_PREFIX}:${MANAGE_PERM_PREFIX}:fs:`); // owner has implicit rule to give others manage access;
},
checker: async ({ actor, permission }) => {
if ( ! (actor.type instanceof UserActorType) ) {
return undefined;
}
const [_, uid] = PermissionUtil.split(permission.replaceAll(`${MANAGE_PERM_PREFIX}:`, ''));
const node = await this.node(new NodeUIDSelector(uid));
if ( ! await node.exists() ) {
return undefined;
}
const owner_id = await node.get('user_id');
// These conditions should never happen
if ( !owner_id || !actor.type.user.id ) {
throw new Error('something unexpected happened');
}
if ( owner_id === actor.type.user.id ) {
return {};
}
return undefined;
},
}));
svc_permission.register_exploder(PermissionExploder.create({
id: 'fs-access-levels',
matcher: permission => {
return permission.startsWith('fs:') &&
PermissionUtil.split(permission).length >= 3;
},
exploder: async ({ permission }) => {
const permissions = [permission];
const [fsPrefix, fileId, specifiedMode, ...rest] = PermissionUtil.split(permission);
const rules = {
see: ['list', 'read', 'write'],
list: ['read', 'write'],
read: ['write'],
};
if ( rules[specifiedMode] ) {
permissions.push(...rules[specifiedMode].map(mode => PermissionUtil.join(fsPrefix, fileId, mode, ...rest.slice(1))));
// push manage permission as well
permissions.push(PermissionUtil.join(MANAGE_PERM_PREFIX, fsPrefix, fileId));
}
return permissions;
},
}));
}
async mkshortcut ({ parent, name, user, target }) {
// Access Control
{
const svc_acl = this.services.get('acl');
if ( ! await svc_acl.check(user, target, 'read') ) {
throw await svc_acl.get_safe_acl_error(user, target, 'read');
}
if ( ! await svc_acl.check(user, parent, 'write') ) {
throw await svc_acl.get_safe_acl_error(user, parent, 'write');
}
}
if ( ! await target.exists() ) {
throw APIError.create('shortcut_to_does_not_exist');
}
if ( ! parent.provider.get_capabilities().has(fsCapabilities.PUTER_SHORTCUT) ) {
throw APIError.create('missing_filesystem_capability', null, {
action: 'make shortcut',
subjectName: parent.path ?? parent.uid,
providerName: parent.provider.name,
capability: 'PUTER_SHORTCUT',
});
}
return await parent.provider.puter_shortcut({
parent, name, user, target,
});
}
async mklink ({ parent, name, user, target }) {
// Access Control
{
const svc_acl = this.services.get('acl');
if ( ! await svc_acl.check(user, parent, 'write') ) {
throw await svc_acl.get_safe_acl_error(user, parent, 'write');
}
}
// We don't check if the target exists because broken links
// are allowed.
const { _path, uuidv4 } = this.modules;
const resourceService = this.services.get('resourceService');
const svc_fsEntry = this.services.get('fsEntryService');
const ts = Math.round(Date.now() / 1000);
const uid = uuidv4();
resourceService.register({
uid,
status: RESOURCE_STATUS_PENDING_CREATE,
});
const raw_fsentry = {
is_symlink: 1,
symlink_path: target,
is_dir: 0,
uuid: uid,
parent_uid: await parent.get('uid'),
path: _path.join(await parent.get('path'), name),
user_id: user.id,
name,
created: ts,
updated: ts,
modified: ts,
immutable: false,
};
this.log.debug('creating symlink', { fsentry: raw_fsentry });
const entryOp = await svc_fsEntry.insert(raw_fsentry);
(async () => {
await entryOp.awaitDone();
this.log.debug('finished creating symlink', { uid });
resourceService.free(uid);
})();
const node = await this.node(new NodeUIDSelector(uid));
const svc_event = this.services.get('event');
svc_event.emit('fs.create.symlink', {
node,
context: Context.get(),
});
return node;
}
async update_child_paths (old_path, new_path, user_id) {
if ( ! old_path.endsWith('/') ) old_path += '/';
if ( ! new_path.endsWith('/') ) new_path += '/';
// TODO: fs:decouple-tree-storage
await this.db.write('UPDATE fsentries SET path = CONCAT(?, SUBSTRING(path, ?)) WHERE path LIKE ? AND user_id = ?',
[new_path, old_path.length + 1, `${old_path}%`, user_id]);
const log = this.services.get('log-service').create('update_child_paths');
log.debug(`updated ${old_path} -> ${new_path}`);
}
/**
* node() returns a filesystem node using path, uid,
* or id associated with a filesystem node. Use this
* method when you need to get a filesystem node and
* need to collect information about the entry.
*
* @param {*} location - path, uid, or id associated with a filesystem node
* @returns
*/
async node (selector) {
if ( typeof selector === 'string' ) {
if ( selector.startsWith('/') ) {
selector = new NodePathSelector(selector);
}
}
// COERCE: legacy selection objects to Node*Selector objects
if (
typeof selector === 'object' &&
selector.constructor.name === 'Object'
) {
if ( selector.path ) {
selector = new NodePathSelector(selector.path);
} else if ( selector.uid ) {
selector = new NodeUIDSelector(selector.uid);
} else {
selector = new NodeInternalIDSelector('mysql', selector.mysql_id);
}
}
if ( ! (selector instanceof NodeSelector) ) {
throw new Error(`FileSystemService could not resolve the specified node value ${
quot(`${ selector}`) } (type: ${typeof selector}) ` +
'to a filesystem node selector');
}
system_dir_check: {
if ( ! (selector instanceof NodePathSelector) ) break system_dir_check;
if ( ! selector.value.startsWith('/') ) break system_dir_check;
// OPTIMIZATION: Check if the path matches a system directory pattern.
const systemDirRegex = /^\/([a-zA-Z0-9_]+)\/(Trash|AppData|Desktop|Documents|Pictures|Videos|Public)$/;
const match = selector.value.match(systemDirRegex);
if ( ! match ) break system_dir_check;
const username = match[1];
const dirName = match[2];
// Get the user object (this is likely cached).
const user = await get_user({ username });
if ( ! user ) break system_dir_check;
let uuidKey = ( selector.value === `/${user.username}` )
? 'home_uuid'
: `${dirName.toLowerCase()}_uuid`; // e.g., 'desktop_uuid'
const cachedUUID = user[uuidKey];
if ( ! cachedUUID ) break system_dir_check;
// If we have a cached ID, use it for more direct lookup.
selector = new NodeUIDSelector(cachedUUID);
}
const svc_mountpoint = this.services.get('mountpoint');
const provider = await svc_mountpoint.get_provider(selector);
let fsNode = new FSNodeContext({
provider,
services: this.services,
selector,
fs: this,
});
return fsNode;
}
/**
* get_entry() returns a filesystem entry using
* path, uid, or id associated with a filesystem
* node. Use this method when you need to get a
* filesystem entry but don't need to collect any
* other information about the entry.
*
* @warning The entry returned by this method is not
* client-safe. Use FSNodeContext to get a client-safe
* entry by calling it's fetchEntry() method.
*
* @param {*} param0 options for getting the entry
* @param {*} param0.path
* @param {*} param0.uid
* @param {*} param0.id please use mysql_id instead
* @param {*} param0.mysql_id
*/
async get_entry ({ path, uid, id, mysql_id, ...options }) {
let fsNode = await this.node({ path, uid, id, mysql_id });
await fsNode.fetchEntry(options);
return fsNode.entry;
}
}
module.exports = {
FilesystemService,
};
================================================
FILE: src/backend/src/filesystem/batch/BatchExecutor.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const PathResolver = require('../../routers/filesystem_api/batch/PathResolver');
const commands = require('./commands').commands;
const APIError = require('../../api/APIError');
const { Context } = require('../../util/context');
const config = require('../../config');
const { TeePromise } = require('@heyputer/putility').libs.promise;
const { WorkUnit } = require('../../modules/core/lib/expect');
class BatchExecutor extends AdvancedBase {
static LOG_LEVEL = true;
constructor (x, { actor, log, errors }) {
super();
this.x = x;
this.actor = actor;
this.pathResolver = new PathResolver({ actor });
this.expectations = x.get('services').get('expectations');
this.log = log;
this.errors = errors;
this.responsePromises = [];
this.hasError = false;
this.total_tbd = true;
this.total = 0;
this.counter = 0;
this.concurrent_ops = 0;
this.max_concurrent_ops = 20;
this.ops_promise = null;
this.log_batchCommands = (config.logging ?? []).includes('batch-commands');
}
async ready_for_more () {
if ( this.ops_promise === null ) {
this.ops_promise = new TeePromise();
}
await this.ops_promise;
}
async exec_op (req, op, file) {
while ( this.concurrent_ops >= this.max_concurrent_ops ) {
await this.ready_for_more();
}
this.concurrent_ops++;
const { expectations } = this;
const command_cls = commands[op.op];
if ( this.log_batchCommands ) {
console.log(command_cls, JSON.stringify(op, null, 2));
}
delete op.op;
const workUnit = WorkUnit.create();
expectations.expect_eventually({
workUnit,
checkpoint: 'operation responded',
});
// TEMP: event service will handle this
op.original_client_socket_id = req.body.original_client_socket_id;
op.socket_id = req.body.socket_id;
// run the operation
let p = this.x.arun(async () => {
const x = Context.get();
if ( ! x ) throw new Error('no context');
try {
if ( ! command_cls ) {
throw APIError.create('invalid_operation', null, {
operation: op.op,
});
}
if ( file ) {
workUnit.checkpoint(`about to run << ${
file.originalname ?? file.name
} >> ${
JSON.stringify(op)}`);
}
const command_ins = await command_cls.run({
getFile: () => file,
pathResolver: this.pathResolver,
actor: this.actor,
}, op);
workUnit.checkpoint('operation invoked');
const res = await command_ins.awaitValue('result');
// const res = await opctx.awaitValue('response');
workUnit.checkpoint('operation responded');
return res;
} catch (e) {
this.hasError = true;
if ( ! ( e instanceof APIError ) ) {
// TODO: alarm condition
this.errors.report('batch-operation', {
source: e,
trace: true,
alarm: true,
});
e = APIError.adapt(e); // eslint-disable-line no-ex-assign
}
// Consume stream if there's a file
if ( file ) {
try {
// read entire stream
await new Promise((resolve, reject) => {
file.stream.on('end', resolve);
file.stream.on('error', reject);
file.stream.resume();
});
} catch (e) {
this.errors.report('batch-operation-2', {
source: e,
trace: true,
alarm: true,
});
}
}
if ( config.env == 'dev' ) {
console.error(e);
// process.exit(1);
}
const serialized_error = e.serialize();
return serialized_error;
} finally {
this.concurrent_ops--;
if ( this.ops_promise && this.concurrent_ops < this.max_concurrent_ops ) {
this.ops_promise.resolve();
this.ops_promise = null;
}
}
});
// decorate with logging
p = p.then(result => {
this.counter++;
const { log, total, total_tbd, counter } = this;
const total_str = total_tbd ? `TBD(>${total})` : `${total}`;
log.debug(`Batch Progress: ${counter} / ${total_str} operations`);
return result;
});
// this.responsePromises.push(p);
// It doesn't really matter whether or not `await` is here
// (that's a design flaw in the Promise API; what if you
// want a promise that returns a promise?)
const result = await p;
return result;
}
}
module.exports = {
BatchExecutor,
};
================================================
FILE: src/backend/src/filesystem/batch/commands.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { AsyncProviderFeature } = require('../../traits/AsyncProviderFeature');
const { HLMkdir, QuickMkdir } = require('../hl_operations/hl_mkdir');
const { Context } = require('../../util/context');
const { HLWrite } = require('../hl_operations/hl_write');
const { get_app } = require('../../helpers');
const { OperationFrame } = require('../../services/OperationTraceService');
const { HLMkShortcut } = require('../hl_operations/hl_mkshortcut');
const { HLMkLink } = require('../hl_operations/hl_mklink');
const { HLRemove } = require('../hl_operations/hl_remove');
const { HLMove } = require('../hl_operations/hl_move');
const { NodeUIDSelector } = require('../node/selectors');
const { safeHasOwnProperty } = require('../../util/safety');
class BatchCommand extends AdvancedBase {
static FEATURES = [
new AsyncProviderFeature(),
];
static async run (executor, parameters) {
const instance = new this();
let x = Context.get();
const operationTraceSvc = x.get('services').get('operationTrace');
const frame = await operationTraceSvc.add_frame(`batch:${ this.name}`);
if ( safeHasOwnProperty(parameters, 'item_upload_id') ) {
frame.attr('gui_metadata', {
...(frame.get_attr('gui_metadata') || {}),
item_upload_id: parameters.item_upload_id,
});
}
x = x.sub({ [operationTraceSvc.ckey('frame')]: frame });
await x.arun(async () => {
await instance.run(executor, parameters);
});
frame.status = OperationFrame.FRAME_STATUS_DONE;
return instance;
}
}
class MkdirCommand extends BatchCommand {
async run (executor, parameters) {
const context = Context.get();
const fs = context.get('services').get('filesystem');
const parent = parameters.parent
? await fs.node(await executor.pathResolver.awaitSelector(parameters.parent))
: undefined ;
const meta = parameters.parent
? executor.pathResolver.getMeta(parameters.parent)
: undefined ;
if ( meta?.conflict_free ) {
// No potential conflict; just create the directory
const q_mkdir = new QuickMkdir();
await q_mkdir.run({
parent,
path: parameters.path,
});
if ( parameters.as ) {
executor.pathResolver.putSelector(
parameters.as,
q_mkdir.created.selector,
{ conflict_free: true },
);
}
this.setFactory('result', async () => {
await q_mkdir.created.awaitStableEntry();
const response = await q_mkdir.created.getSafeEntry();
return response;
});
return;
}
const hl_mkdir = new HLMkdir();
const response = await hl_mkdir.run({
parent,
path: parameters.path,
overwrite: parameters.overwrite,
dedupe_name: parameters.dedupe_name,
create_missing_parents:
parameters.create_missing_ancestors ??
parameters.create_missing_parents ??
false,
shortcut_to: parameters.shortcut_to,
actor: executor.actor,
});
if ( parameters.as ) {
executor.pathResolver.putSelector(
parameters.as,
hl_mkdir.created.selector,
hl_mkdir.used_existing
? undefined
: { conflict_free: true },
);
}
this.provideValue('result', response);
}
}
class WriteCommand extends BatchCommand {
async run (executor, parameters) {
const context = Context.get();
const fs = context.get('services').get('filesystem');
const uploaded_file = executor.getFile();
const destinationOrParent =
await fs.node(await executor.pathResolver.awaitSelector(parameters.path));
let app;
if ( parameters.app_uid ) {
app = await get_app({ uid: parameters.app_uid });
}
const hl_write = new HLWrite();
if ( ! executor.actor ) {
throw new Error('Actor is missing here');
}
const response = await hl_write.run({
destination_or_parent: destinationOrParent,
specified_name: parameters.name,
fallback_name: uploaded_file.originalname,
overwrite: parameters.overwrite,
dedupe_name: parameters.dedupe_name,
create_missing_parents:
parameters.create_missing_ancestors ??
parameters.create_missing_parents ??
false,
actor: executor.actor,
file: uploaded_file,
offset: parameters.offset,
// TODO: handle these with event service instead
socket_id: parameters.socket_id,
operation_id: parameters.operation_id,
item_upload_id: parameters.item_upload_id,
app_id: app ? app.id : null,
thumbnail: parameters.thumbnail,
});
this.provideValue('result', response);
// const opctx = await fs.write(fs, {
// // --- per file ---
// name: parameters.name,
// fallbackName: uploaded_file.originalname,
// destinationOrParent,
// // app_id: app ? app.id : null,
// overwrite: parameters.overwrite,
// dedupe_name: parameters.dedupe_name,
// file: uploaded_file,
// thumbnail: parameters.thumbnail,
// target: parameters.target ? await req.fs.node(parameters.shortcut_to) : null,
// symlink_path: parameters.symlink_path,
// operation_id: parameters.operation_id,
// item_upload_id: parameters.item_upload_id,
// user: executor.user,
// // --- per batch ---
// socket_id: parameters.socket_id,
// original_client_socket_id: parameters.original_client_socket_id,
// });
// opctx.onValue('response', v => this.provideValue('result', v));
}
}
class ShortcutCommand extends BatchCommand {
async run (executor, parameters) {
const context = Context.get();
const fs = context.get('services').get('filesystem');
const destinationOrParent =
await fs.node(await executor.pathResolver.awaitSelector(parameters.path));
const shortcut_to =
await fs.node(await executor.pathResolver.awaitSelector(parameters.shortcut_to));
let app;
if ( parameters.app_uid ) {
app = await get_app({ uid: parameters.app_uid });
}
await destinationOrParent.fetchEntry({ thumbnail: true });
await shortcut_to.fetchEntry({ thumbnail: true });
const hl_mkShortcut = new HLMkShortcut();
const response = await hl_mkShortcut.run({
parent: destinationOrParent,
name: parameters.name,
actor: executor.actor,
target: shortcut_to,
dedupe_name: parameters.dedupe_name,
// TODO: handle these with event service instead
socket_id: parameters.socket_id,
operation_id: parameters.operation_id,
item_upload_id: parameters.item_upload_id,
app_id: app ? app.id : null,
});
this.provideValue('result', response);
}
}
class SymlinkCommand extends BatchCommand {
async run (executor, parameters) {
const context = Context.get();
const fs = context.get('services').get('filesystem');
const destinationOrParent =
await fs.node(await executor.pathResolver.awaitSelector(parameters.path));
let app;
if ( parameters.app_uid ) {
app = await get_app({ uid: parameters.app_uid });
}
await destinationOrParent.fetchEntry({ thumbnail: true });
const hl_mkLink = new HLMkLink();
const response = await hl_mkLink.run({
parent: destinationOrParent,
name: parameters.name,
actor: executor.actor,
target: parameters.target,
// TODO: handle these with event service instead
socket_id: parameters.socket_id,
operation_id: parameters.operation_id,
item_upload_id: parameters.item_upload_id,
app_id: app ? app.id : null,
});
this.provideValue('result', response);
}
}
class DeleteCommand extends BatchCommand {
async run (executor, parameters) {
const context = Context.get();
const fs = context.get('services').get('filesystem');
const target =
await fs.node(await executor.pathResolver.awaitSelector(parameters.path));
const hl_remove = new HLRemove();
const response = await hl_remove.run({
target,
actor: executor.actor,
recursive: parameters.recursive ?? false,
descendants_only: parameters.descendants_only ?? false,
});
this.provideValue('result', response);
}
}
class MoveCommand extends BatchCommand {
async run (executor, parameters) {
const context = Context.get();
const fs = context.get('services').get('filesystem');
console.log('what are the parameters???', parameters);
const source =
await fs.node(await executor.pathResolver.awaitSelector(parameters.source));
const destinationOrParent =
await fs.node(await executor.pathResolver.awaitSelector(parameters.destination));
const hl_move = new HLMove();
const response = await hl_move.run({
source,
destination_or_parent: destinationOrParent,
actor: executor.actor,
new_name: parameters.new_name,
overwrite: parameters.overwrite ?? false,
dedupe_name: parameters.dedupe_name ?? parameters.change_name ?? false,
create_missing_parents:
parameters.create_missing_ancestors ??
parameters.create_missing_parents ??
false,
new_metadata: parameters.new_metadata,
});
if ( parameters.as && response.moved?.uid ) {
executor.pathResolver.putSelector(parameters.as, new NodeUIDSelector(response.moved.uid));
}
this.provideValue('result', response);
}
}
module.exports = {
commands: {
mkdir: MkdirCommand,
write: WriteCommand,
shortcut: ShortcutCommand,
symlink: SymlinkCommand,
delete: DeleteCommand,
move: MoveCommand,
},
};
================================================
FILE: src/backend/src/filesystem/definitions/capabilities.js
================================================
/*
* 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 .
*/
const capabilityNames = [
// PuterFS Capabilities
'thumbnail',
'uuid',
'operation-trace',
'readdir-uuid-mode',
'update-thumbnail',
'puter-shortcut',
// Standard Capabilities
'read',
'write',
'symlink',
'trash',
// Macro Capabilities
'copy-tree',
'move-tree',
'remove-tree',
'get-recursive-size',
'readdirstat_uuid',
// Behavior Capabilities
'case-sensitive',
// POSIX Capabilities
'readdir-inode-numbers',
'unix-perms',
];
const fsCapabilities = {};
for ( const capabilityName of capabilityNames ) {
const key = capabilityName.toUpperCase().replace(/-/g, '_');
fsCapabilities[key] = Symbol(capabilityName);
}
module.exports = fsCapabilities;
================================================
FILE: src/backend/src/filesystem/definitions/proto/fsentry.proto
================================================
syntax = "proto3";
// The FSEntry from client's (puter-js, http API) perspective, it's used for
// - end to end test
// - backend logic
// - communication between servers
message FSEntry {
string uuid = 1;
// Same as uuid, used for backward compatibility.
string uid = 2;
string name = 3;
string path = 4;
string parent_uuid = 5;
// Same as parent_uuid, used for backward compatibility.
string parent_uid = 6;
// Same as parent_uuid, used for backward compatibility.
string parent_id = 7;
bool is_dir = 8;
int64 created = 9;
int64 modified = 10;
int64 accessed = 11;
int64 size = 12;
}
================================================
FILE: src/backend/src/filesystem/definitions/ts/fsentry.js
================================================
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
export const protobufPackage = "";
function createBaseFSEntry() {
return {
uuid: "",
uid: "",
name: "",
path: "",
parent_uuid: "",
parent_uid: "",
parent_id: "",
is_dir: false,
created: 0,
modified: 0,
accessed: 0,
size: 0,
};
}
export const FSEntry = {
encode(message, writer = new BinaryWriter()) {
if (message.uuid !== "") {
writer.uint32(10).string(message.uuid);
}
if (message.uid !== "") {
writer.uint32(18).string(message.uid);
}
if (message.name !== "") {
writer.uint32(26).string(message.name);
}
if (message.path !== "") {
writer.uint32(34).string(message.path);
}
if (message.parent_uuid !== "") {
writer.uint32(42).string(message.parent_uuid);
}
if (message.parent_uid !== "") {
writer.uint32(50).string(message.parent_uid);
}
if (message.parent_id !== "") {
writer.uint32(58).string(message.parent_id);
}
if (message.is_dir !== false) {
writer.uint32(64).bool(message.is_dir);
}
if (message.created !== 0) {
writer.uint32(72).int64(message.created);
}
if (message.modified !== 0) {
writer.uint32(80).int64(message.modified);
}
if (message.accessed !== 0) {
writer.uint32(88).int64(message.accessed);
}
if (message.size !== 0) {
writer.uint32(96).int64(message.size);
}
return writer;
},
decode(input, length) {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseFSEntry();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.uuid = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.uid = reader.string();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.name = reader.string();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.path = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.parent_uuid = reader.string();
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.parent_uid = reader.string();
continue;
}
case 7: {
if (tag !== 58) {
break;
}
message.parent_id = reader.string();
continue;
}
case 8: {
if (tag !== 64) {
break;
}
message.is_dir = reader.bool();
continue;
}
case 9: {
if (tag !== 72) {
break;
}
message.created = longToNumber(reader.int64());
continue;
}
case 10: {
if (tag !== 80) {
break;
}
message.modified = longToNumber(reader.int64());
continue;
}
case 11: {
if (tag !== 88) {
break;
}
message.accessed = longToNumber(reader.int64());
continue;
}
case 12: {
if (tag !== 96) {
break;
}
message.size = longToNumber(reader.int64());
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object) {
return {
uuid: isSet(object.uuid) ? globalThis.String(object.uuid) : "",
uid: isSet(object.uid) ? globalThis.String(object.uid) : "",
name: isSet(object.name) ? globalThis.String(object.name) : "",
path: isSet(object.path) ? globalThis.String(object.path) : "",
parent_uuid: isSet(object.parent_uuid) ? globalThis.String(object.parent_uuid) : "",
parent_uid: isSet(object.parent_uid) ? globalThis.String(object.parent_uid) : "",
parent_id: isSet(object.parent_id) ? globalThis.String(object.parent_id) : "",
is_dir: isSet(object.is_dir) ? globalThis.Boolean(object.is_dir) : false,
created: isSet(object.created) ? globalThis.Number(object.created) : 0,
modified: isSet(object.modified) ? globalThis.Number(object.modified) : 0,
accessed: isSet(object.accessed) ? globalThis.Number(object.accessed) : 0,
size: isSet(object.size) ? globalThis.Number(object.size) : 0,
};
},
toJSON(message) {
const obj = {};
if (message.uuid !== "") {
obj.uuid = message.uuid;
}
if (message.uid !== "") {
obj.uid = message.uid;
}
if (message.name !== "") {
obj.name = message.name;
}
if (message.path !== "") {
obj.path = message.path;
}
if (message.parent_uuid !== "") {
obj.parent_uuid = message.parent_uuid;
}
if (message.parent_uid !== "") {
obj.parent_uid = message.parent_uid;
}
if (message.parent_id !== "") {
obj.parent_id = message.parent_id;
}
if (message.is_dir !== false) {
obj.is_dir = message.is_dir;
}
if (message.created !== 0) {
obj.created = Math.round(message.created);
}
if (message.modified !== 0) {
obj.modified = Math.round(message.modified);
}
if (message.accessed !== 0) {
obj.accessed = Math.round(message.accessed);
}
if (message.size !== 0) {
obj.size = Math.round(message.size);
}
return obj;
},
create(base) {
return FSEntry.fromPartial(base ?? {});
},
fromPartial(object) {
const message = createBaseFSEntry();
message.uuid = object.uuid ?? "";
message.uid = object.uid ?? "";
message.name = object.name ?? "";
message.path = object.path ?? "";
message.parent_uuid = object.parent_uuid ?? "";
message.parent_uid = object.parent_uid ?? "";
message.parent_id = object.parent_id ?? "";
message.is_dir = object.is_dir ?? false;
message.created = object.created ?? 0;
message.modified = object.modified ?? 0;
message.accessed = object.accessed ?? 0;
message.size = object.size ?? 0;
return message;
},
};
function longToNumber(int64) {
const num = globalThis.Number(int64.toString());
if (num > globalThis.Number.MAX_SAFE_INTEGER) {
throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER");
}
if (num < globalThis.Number.MIN_SAFE_INTEGER) {
throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER");
}
return num;
}
function isSet(value) {
return value !== null && value !== undefined;
}
//# sourceMappingURL=fsentry.js.map
================================================
FILE: src/backend/src/filesystem/definitions/ts/fsentry.ts
================================================
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.8.0
// protoc v3.21.12
// source: fsentry.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
export const protobufPackage = "";
/**
* The FSEntry from client's (puter-js, http API) perspective, it's used for
* - end to end test
* - backend logic
* - communication between servers
*/
export interface FSEntry {
uuid: string;
/** Same as uuid, used for backward compatibility. */
uid: string;
name: string;
path: string;
parent_uuid: string;
/** Same as parent_uuid, used for backward compatibility. */
parent_uid: string;
/** Same as parent_uuid, used for backward compatibility. */
parent_id: string;
is_dir: boolean;
created: number;
modified: number;
accessed: number;
size: number;
}
function createBaseFSEntry(): FSEntry {
return {
uuid: "",
uid: "",
name: "",
path: "",
parent_uuid: "",
parent_uid: "",
parent_id: "",
is_dir: false,
created: 0,
modified: 0,
accessed: 0,
size: 0,
};
}
export const FSEntry: MessageFns = {
encode(message: FSEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.uuid !== "") {
writer.uint32(10).string(message.uuid);
}
if (message.uid !== "") {
writer.uint32(18).string(message.uid);
}
if (message.name !== "") {
writer.uint32(26).string(message.name);
}
if (message.path !== "") {
writer.uint32(34).string(message.path);
}
if (message.parent_uuid !== "") {
writer.uint32(42).string(message.parent_uuid);
}
if (message.parent_uid !== "") {
writer.uint32(50).string(message.parent_uid);
}
if (message.parent_id !== "") {
writer.uint32(58).string(message.parent_id);
}
if (message.is_dir !== false) {
writer.uint32(64).bool(message.is_dir);
}
if (message.created !== 0) {
writer.uint32(72).int64(message.created);
}
if (message.modified !== 0) {
writer.uint32(80).int64(message.modified);
}
if (message.accessed !== 0) {
writer.uint32(88).int64(message.accessed);
}
if (message.size !== 0) {
writer.uint32(96).int64(message.size);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): FSEntry {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseFSEntry();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.uuid = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.uid = reader.string();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.name = reader.string();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.path = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.parent_uuid = reader.string();
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.parent_uid = reader.string();
continue;
}
case 7: {
if (tag !== 58) {
break;
}
message.parent_id = reader.string();
continue;
}
case 8: {
if (tag !== 64) {
break;
}
message.is_dir = reader.bool();
continue;
}
case 9: {
if (tag !== 72) {
break;
}
message.created = longToNumber(reader.int64());
continue;
}
case 10: {
if (tag !== 80) {
break;
}
message.modified = longToNumber(reader.int64());
continue;
}
case 11: {
if (tag !== 88) {
break;
}
message.accessed = longToNumber(reader.int64());
continue;
}
case 12: {
if (tag !== 96) {
break;
}
message.size = longToNumber(reader.int64());
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): FSEntry {
return {
uuid: isSet(object.uuid) ? globalThis.String(object.uuid) : "",
uid: isSet(object.uid) ? globalThis.String(object.uid) : "",
name: isSet(object.name) ? globalThis.String(object.name) : "",
path: isSet(object.path) ? globalThis.String(object.path) : "",
parent_uuid: isSet(object.parent_uuid) ? globalThis.String(object.parent_uuid) : "",
parent_uid: isSet(object.parent_uid) ? globalThis.String(object.parent_uid) : "",
parent_id: isSet(object.parent_id) ? globalThis.String(object.parent_id) : "",
is_dir: isSet(object.is_dir) ? globalThis.Boolean(object.is_dir) : false,
created: isSet(object.created) ? globalThis.Number(object.created) : 0,
modified: isSet(object.modified) ? globalThis.Number(object.modified) : 0,
accessed: isSet(object.accessed) ? globalThis.Number(object.accessed) : 0,
size: isSet(object.size) ? globalThis.Number(object.size) : 0,
};
},
toJSON(message: FSEntry): unknown {
const obj: any = {};
if (message.uuid !== "") {
obj.uuid = message.uuid;
}
if (message.uid !== "") {
obj.uid = message.uid;
}
if (message.name !== "") {
obj.name = message.name;
}
if (message.path !== "") {
obj.path = message.path;
}
if (message.parent_uuid !== "") {
obj.parent_uuid = message.parent_uuid;
}
if (message.parent_uid !== "") {
obj.parent_uid = message.parent_uid;
}
if (message.parent_id !== "") {
obj.parent_id = message.parent_id;
}
if (message.is_dir !== false) {
obj.is_dir = message.is_dir;
}
if (message.created !== 0) {
obj.created = Math.round(message.created);
}
if (message.modified !== 0) {
obj.modified = Math.round(message.modified);
}
if (message.accessed !== 0) {
obj.accessed = Math.round(message.accessed);
}
if (message.size !== 0) {
obj.size = Math.round(message.size);
}
return obj;
},
create(base?: DeepPartial): FSEntry {
return FSEntry.fromPartial(base ?? {});
},
fromPartial(object: DeepPartial): FSEntry {
const message = createBaseFSEntry();
message.uuid = object.uuid ?? "";
message.uid = object.uid ?? "";
message.name = object.name ?? "";
message.path = object.path ?? "";
message.parent_uuid = object.parent_uuid ?? "";
message.parent_uid = object.parent_uid ?? "";
message.parent_id = object.parent_id ?? "";
message.is_dir = object.is_dir ?? false;
message.created = object.created ?? 0;
message.modified = object.modified ?? 0;
message.accessed = object.accessed ?? 0;
message.size = object.size ?? 0;
return message;
},
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial = T extends Builtin ? T
: T extends globalThis.Array ? globalThis.Array>
: T extends ReadonlyArray ? ReadonlyArray>
: T extends {} ? { [K in keyof T]?: DeepPartial }
: Partial;
function longToNumber(int64: { toString(): string }): number {
const num = globalThis.Number(int64.toString());
if (num > globalThis.Number.MAX_SAFE_INTEGER) {
throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER");
}
if (num < globalThis.Number.MIN_SAFE_INTEGER) {
throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER");
}
return num;
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
export interface MessageFns {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create(base?: DeepPartial): T;
fromPartial(object: DeepPartial): T;
}
================================================
FILE: src/backend/src/filesystem/hl_operations/definitions.js
================================================
/*
* 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 .
*/
const { BaseOperation } = require('../../services/OperationTraceService');
class HLFilesystemOperation extends BaseOperation {
}
module.exports = {
HLFilesystemOperation,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_copy.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { chkperm, validate_fsentry_name, get_user, is_ancestor_of } = require('../../helpers');
const { TYPE_DIRECTORY } = require('../FSNodeContext');
const { NodePathSelector, RootNodeSelector } = require('../node/selectors');
const { HLFilesystemOperation } = require('./definitions');
const { MkTree } = require('./hl_mkdir');
const { HLRemove } = require('./hl_remove');
const { LLCopy } = require('../ll_operations/ll_copy');
const { getTracer } = require('../../util/otelutil');
class HLCopy extends HLFilesystemOperation {
static DESCRIPTION = `
High-level copy operation.
This operation is a wrapper around the low-level copy operation.
It provides the following features:
- create missing parent directories
- overwrite existing files or directories
- deduplicate files/directories with the same name
`;
static MODULES = {
_path: require('path'),
};
static PARAMETERS = {
source: {},
destionation_or_parent: {},
new_name: {},
overwrite: {},
dedupe_name: {},
create_missing_parents: {},
user: {},
};
async _run () {
const { _path } = this.modules;
const { values, context } = this;
const svc = context.get('services');
const fs = svc.get('filesystem');
let parent = values.destination_or_parent;
let dest = null;
const source = values.source;
if ( values.overwrite && values.dedupe_name ) {
throw APIError.create('overwrite_and_dedupe_exclusive');
}
if ( ! await source.exists() ) {
throw APIError.create('source_does_not_exist');
}
if ( ! await chkperm(source.entry, values.user.id, 'cp') ) {
throw APIError.create('forbidden');
}
if ( await parent.get('is-root') ) {
throw APIError.create('cannot_copy_to_root');
}
// If parent exists and is a file, and a new name wasn't
// specified, the intention must be to overwrite the file.
if (
!values.new_name &&
await parent.exists() &&
await parent.get('type') !== TYPE_DIRECTORY
) {
dest = parent;
parent = await dest.getParent();
await parent.fetchEntry();
}
// If parent is not found either throw an error or create
// the parent directory as specified by parameters.
if ( ! await parent.exists() ) {
if ( ! (parent.selector instanceof NodePathSelector) ) {
throw APIError.create('dest_does_not_exist', null, {
parent: parent.selector,
});
}
const path = parent.selector.value;
const tree_op = new MkTree();
await tree_op.run({
parent: await fs.node(new RootNodeSelector()),
tree: [path],
});
await parent.fetchEntry({ force: true });
}
if (
await parent.get('type') !== TYPE_DIRECTORY
) {
throw APIError.create('dest_is_not_a_directory');
}
if ( ! await chkperm(parent.entry, values.user.id, 'write') ) {
throw APIError.create('forbidden');
}
let target_name = values.new_name ?? await source.get('name');
try {
validate_fsentry_name(target_name);
} catch (e) {
throw APIError.create(400, e);
}
// NEXT: implement _verify_room with profiling
const tracer = getTracer();
await tracer.startActiveSpan('fs:cp:verify-size-constraints', async span => {
const source_file = source.entry;
const dest_fsentry = parent.entry;
let source_user = await get_user({ id: source_file.user_id });
let dest_user = source_user.id !== dest_fsentry.user_id
? await get_user({ id: dest_fsentry.user_id })
: source_user ;
const sizeService = svc.get('sizeService');
let deset_usage = await sizeService.get_usage(dest_user.id);
const size = await source.fetchSize();
const capacity = await sizeService.get_storage_capacity(dest_user.id);
if ( capacity - deset_usage - size < 0 ) {
throw APIError.create('storage_limit_reached');
}
span.end();
});
if ( dest === null ) {
dest = await parent.getChild(target_name);
}
// Ensure copy operation is legal
// TODO: maybe this is better in the low-level operation
if ( await source.get('uid') == await parent.get('uid') ) {
throw APIError.create('source_and_dest_are_the_same');
}
if ( await is_ancestor_of(source.uid, parent.uid) ) {
throw APIError.create('cannot_copy_item_into_itself');
}
let overwritten;
if ( await dest.exists() ) {
// condition: no overwrite behaviour specified
if ( !values.overwrite && !values.dedupe_name ) {
throw APIError.create('item_with_same_name_exists', null, {
entry_name: dest.entry.name,
});
}
if ( values.dedupe_name ) {
const target_ext = _path.extname(target_name);
const target_noext = _path.basename(target_name, target_ext);
for ( let i = 1 ;; i++ ) {
const try_new_name = `${target_noext} (${i})${target_ext}`;
const exists = await parent.hasChild(try_new_name);
if ( ! exists ) {
target_name = try_new_name;
break;
}
}
dest = await parent.getChild(target_name);
}
else if ( values.overwrite ) {
if ( ! await chkperm(dest.entry, values.user.id, 'rm') ) {
throw APIError.create('forbidden');
}
// TODO: This will be LLRemove
// TODO: what to do with parent_operation?
overwritten = await dest.getSafeEntry();
const hl_remove = new HLRemove();
await hl_remove.run({
target: dest,
user: values.user,
recursive: true,
});
}
}
const ll_copy = new LLCopy();
this.copied = await ll_copy.run({
source,
parent,
user: values.user,
target_name,
});
await this.copied.awaitStableEntry();
const response = await this.copied.getSafeEntry({ thumbnail: true });
return {
copied: response,
overwritten,
};
}
}
module.exports = {
HLCopy,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_data_read.js
================================================
/*
* 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 .
*/
const { HLFilesystemOperation } = require('./definitions');
const { chkperm } = require('../../helpers');
const { LLRead } = require('../ll_operations/ll_read');
const APIError = require('../../api/APIError');
/**
* HLDataRead reads a stream of objects from a file containing structured data.
* For .jsonl files, the stream will product multiple objects.
* For .json files, the stream will produce a single object.
*/
class HLDataRead extends HLFilesystemOperation {
static MODULES = {
'stream': require('stream'),
};
async _run () {
const { context } = this;
// We get the user from context so that an elevated system context
// can read files under the system user.
const user = await context.get('user');
const {
fsNode,
version_id,
} = this.values;
if ( ! await fsNode.exists() ) {
throw APIError.create('subject_does_not_exist');
}
if ( ! await chkperm(fsNode.entry, user.id, 'read') ) {
throw APIError.create('forbidden');
}
const ll_read = new LLRead();
let stream = await ll_read.run({
fsNode,
user,
version_id,
});
stream = this._stream_bytes_to_lines(stream);
stream = this._stream_jsonl_lines_to_objects(stream);
return stream;
}
_stream_bytes_to_lines (stream) {
const readline = require('readline');
const rl = readline.createInterface({
input: stream,
terminal: false,
});
const { PassThrough } = this.modules.stream;
const output_stream = new PassThrough();
rl.on('line', (line) => {
output_stream.write(line);
});
rl.on('close', () => {
output_stream.end();
});
return output_stream;
}
_stream_jsonl_lines_to_objects (stream) {
const { PassThrough } = this.modules.stream;
const output_stream = new PassThrough();
(async () => {
for await ( const line of stream ) {
output_stream.write(JSON.parse(line));
}
output_stream.end();
})();
return output_stream;
}
}
module.exports = {
HLDataRead,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_mkdir.js
================================================
/*
* 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 .
*/
const { chkperm } = require('../../helpers');
const { RootNodeSelector, NodeChildSelector, NodePathSelector } = require('../node/selectors');
const APIError = require('../../api/APIError');
const FSNodeParam = require('../../api/filesystem/FSNodeParam');
const StringParam = require('../../api/filesystem/StringParam');
const FlagParam = require('../../api/filesystem/FlagParam');
const UserParam = require('../../api/filesystem/UserParam');
const FSNodeContext = require('../FSNodeContext');
const { OtelFeature } = require('../../traits/OtelFeature');
const { HLFilesystemOperation } = require('./definitions');
const { is_valid_path } = require('../validation');
const { HLRemove } = require('./hl_remove');
const { LLMkdir } = require('../ll_operations/ll_mkdir');
/**
* Creates a directory, handling race conditions where another parallel request
* may have already created the same directory.
*
* @param {Object} params
* @param {FSNodeContext} params.parent - parent directory
* @param {NodeChildSelector} params.selector - selector for directory (contains name)
* @param {Actor} params.actor - actor to perform the operation on behalf of
* @param {Object} params.fs - filesystem service
* @returns {Promise} created or existing directory node
*/
async function createDirOrUseExisting ({ parent, selector, actor, fs }) {
try {
const ll_mkdir = new LLMkdir();
return await ll_mkdir.run({
parent,
name: selector.name,
actor,
});
} catch ( error ) {
// This "error" can occur when multiple `hl_mkdir` operations are being
// run at the same time with the `createMissingParents` option enabled.
const errorCode = error.code || error.fields?.code;
if ( errorCode === 'item_with_same_name_exists' ) {
const existing_node = await fs.node(selector);
// Wait for the entry to be stable (it might still be in the process
// of being created by another parallel request)
await existing_node.awaitStableEntry();
await existing_node.fetchEntry();
// If this is a file we need to re-throw the error
if ( await existing_node.get('type') !== FSNodeContext.TYPE_DIRECTORY ) {
throw error;
}
return existing_node;
}
throw error;
}
}
class MkTree extends HLFilesystemOperation {
static DESCRIPTION = `
High-level operation for making directory trees
The following input for 'tree':
['a/b/c', ['i/j/k'], ['p', ['q'], ['r/s']]]]
Would create a directory tree like this:
a
└── b
└── c
├── i
│ └── j
│ └── k
└── p
├── q
└── r
└── s
`;
static PARAMETERS = {
parent: new FSNodeParam('parent', { optional: true }),
};
static PROPERTIES = {
leaves: () => [],
directories_created: () => [],
};
async _run () {
const { values, context } = this;
const fs = context.get('services').get('filesystem');
await this.create_branch_({
parent_node: values.parent || await fs.node(new RootNodeSelector()),
tree: values.tree,
parent_exists: true,
});
}
async create_branch_ ({ parent_node, tree, parent_exists }) {
const { context } = this;
const fs = context.get('services').get('filesystem');
const actor = context.get('actor');
const trunk = tree[0];
const branches = tree.slice(1);
let current = parent_node.selector;
// trunk = a/b/c
const dirs = trunk === '.' ? []
: trunk.split('/').filter(Boolean);
// dirs = [a, b, c]
let parent_did_exist = parent_exists;
// This is just a loop that goes through each part of the path
// until it finds the first directory that doesn't exist yet.
let i = 0;
if ( parent_exists ) {
for ( ; i < dirs.length ; i++ ) {
const dir = dirs[i];
const currentParent = current;
current = new NodeChildSelector(current, dir);
const maybe_dir = await fs.node(current);
if ( maybe_dir.isRoot ) continue;
if ( await maybe_dir.isUserDirectory() ) continue;
if ( await maybe_dir.exists() ) {
if ( await maybe_dir.get('type') !== FSNodeContext.TYPE_DIRECTORY ) {
throw APIError.create('dest_is_not_a_directory');
}
continue;
}
current = currentParent;
parent_exists = false;
break;
}
}
if ( parent_did_exist && !parent_exists ) {
const node = await fs.node(current);
const has_perm = await chkperm(await node.get('entry'), actor.type.user.id, 'write');
if ( ! has_perm ) throw APIError.create('permission_denied');
}
// This next loop creates the new directories
// We break into a second loop because we know none of these directories
// exist yet. If we continued those checks each child operation would
// wait for the previous one to complete because FSNodeContext::fetchEntry
// will notice ResourceService has a lock on the previous operation
// we started.
// In this way it goes nyyyoooom because all the database inserts
// happen concurrently (and probably end up in the same batch).
for ( ; i < dirs.length ; i++ ) {
const dir = dirs[i];
const currentParent = current;
current = new NodeChildSelector(current, dir);
const node = await createDirOrUseExisting({
parent: await fs.node(currentParent),
selector: current,
actor,
fs,
});
current = node.selector;
this.directories_created.push(node);
}
const bottom_parent = await fs.node(current);
if ( branches.length === 0 ) {
this.leaves.push(bottom_parent);
}
for ( const branch of branches ) {
await this.create_branch_({
parent_node: bottom_parent,
tree: branch,
parent_exists,
});
}
}
}
class QuickMkdir extends HLFilesystemOperation {
async _run () {
const { context, values } = this;
let { parent, path } = values;
const { _path } = this.modules;
const fs = context.get('services').get('filesystem');
const actor = context.get('actor');
parent = parent || await fs.node(new RootNodeSelector());
let current = parent.selector;
const dirs = path === '.' ? []
: path.split('/').filter(Boolean);
const api = require('@opentelemetry/api');
const currentSpan = api.trace.getSpan(api.context.active());
if ( currentSpan ) {
currentSpan.setAttribute('path', path);
currentSpan.setAttribute('dirs', dirs.join('/'));
currentSpan.setAttribute('parent', parent.selector.describe());
}
for ( let i = 0 ; i < dirs.length ; i++ ) {
const dir = dirs[i];
const currentParent = current;
current = new NodeChildSelector(current, dir);
const node = await createDirOrUseExisting({
parent: await fs.node(currentParent),
selector: current,
actor,
fs,
});
current = node.selector;
// this.directories_created.push(node);
}
this.created = await fs.node(current);
}
}
class HLMkdir extends HLFilesystemOperation {
static DESCRIPTION = `
High-level mkdir operation.
This operation is a wrapper around the low-level mkdir operation.
It provides the following features:
- create missing parent directories
- overwrite existing files
- dedupe names
- create shortcuts
`;
static PARAMETERS = {
parent: new FSNodeParam('parent', { optional: true }),
path: new StringParam('path'),
overwrite: new FlagParam('overwrite', { optional: true }),
create_missing_parents: new FlagParam('create_missing_parents', { optional: true }),
user: new UserParam(),
shortcut_to: new FSNodeParam('shortcut_to', { optional: true }),
};
static MODULES = {
_path: require('path'),
};
static PROPERTIES = {
parent_directories_created: () => [],
};
static FEATURES = [
new OtelFeature([
'_get_existing_parent',
'_create_parents',
]),
];
async _run () {
const { context, values } = this;
const { _path } = this.modules;
const fs = context.get('services').get('filesystem');
if ( ! is_valid_path(values.path, {
no_relative_components: true,
allow_path_fragment: true,
}) ) {
throw APIError.create('field_invalid', null, {
key: 'path',
expected: 'valid path',
got: 'invalid path',
});
}
// Unify the following formats:
// - full path: {"path":"/foo/bar", args...}, used by apitest (./tools/api-tester/apitest.js)
// - parent + path: {"parent": "/foo", "path":"bar", args...}, used by puter-js (puter.fs.mkdir("/foo/bar"))
if ( !values.parent && values.path ) {
values.parent = await fs.node(new NodePathSelector(_path.dirname(values.path)));
values.path = _path.basename(values.path);
}
let parent_node = values.parent || await fs.node(new RootNodeSelector());
let target_basename = _path.basename(values.path);
// "top_parent" is the immediate parent of the target directory
// (e.g: /home/foo/bar -> /home/foo)
const top_parent = values.create_missing_parents
? await this._create_dir(parent_node)
: await this._get_existing_top_parent({ top_parent: parent_node })
;
// TODO: this can be removed upon completion of: https://github.com/HeyPuter/puter/issues/1352
if ( top_parent.isRoot ) {
// root directory is read-only
throw APIError.create('forbidden', null, {
message: 'Cannot create directories in the root directory.',
});
}
// `parent_node` becomes the parent of the last directory name
// specified under `path`.
parent_node = await this._create_parents({
parent_node: top_parent,
actor: values.actor,
});
const user_id = values.actor.type.user.id;
const has_perm = await chkperm(await parent_node.get('entry'), user_id, 'write');
if ( ! has_perm ) throw APIError.create('permission_denied');
const existing = await fs.node(new NodeChildSelector(parent_node.selector, target_basename));
await existing.fetchEntry();
if ( existing.found ) {
const { overwrite, dedupe_name, create_missing_parents } = values;
if ( overwrite ) {
// TODO: tag rm operation somehow
const has_perm = await chkperm(await existing.get('entry'), user_id, 'write');
if ( ! has_perm ) throw APIError.create('permission_denied');
const hl_remove = new HLRemove();
await hl_remove.run({
target: existing,
actor: values.actor,
recursive: true,
});
}
else if ( dedupe_name ) {
const fs = context.get('services').get('filesystem');
const parent_selector = parent_node.selector;
for ( let i = 1 ;; i++ ) {
let try_new_name = `${target_basename} (${i})`;
const selector = new NodeChildSelector(parent_selector, try_new_name);
const exists = await parent_node.provider.quick_check({
selector,
});
if ( ! exists ) {
target_basename = try_new_name;
break;
}
}
}
else if ( create_missing_parents ) {
if ( ! existing.entry.is_dir ) {
throw APIError.create('dest_is_not_a_directory');
}
this.created = existing;
this.used_existing = true;
return await this.created.getSafeEntry();
} else {
throw APIError.create('item_with_same_name_exists', null, {
entry_name: target_basename,
});
}
}
if ( values.shortcut_to ) {
const shortcut_to = values.shortcut_to;
if ( ! await shortcut_to.exists() ) {
throw APIError.create('shortcut_to_does_not_exist');
}
if ( ! shortcut_to.entry.is_dir ) {
throw APIError.create('shortcut_target_is_a_directory');
}
const has_perm = await chkperm(shortcut_to.entry, user_id, 'read');
if ( ! has_perm ) throw APIError.create('forbidden');
this.created = await fs.mkshortcut({
parent: parent_node,
name: target_basename,
actor: values.actor,
target: shortcut_to,
});
await this.created.awaitStableEntry();
return await this.created.getSafeEntry();
}
let created_node;
try {
const ll_mkdir = new LLMkdir();
created_node = await ll_mkdir.run({
parent: parent_node,
name: target_basename,
actor: values.actor,
});
} catch ( error ) {
// This "error" can occur when multiple `hl_mkdir` operations are being
// run at the same time with the `createMissingParents` option enabled.
const errorCode = error.code || error.fields?.code;
if ( errorCode === 'item_with_same_name_exists' ) {
const existing_node = await fs.node(new NodeChildSelector(parent_node.selector, target_basename));
// Wait for the entry to be stable (it might still be in the process
// of being created by another parallel request)
await existing_node.awaitStableEntry();
await existing_node.fetchEntry();
// If this is a file we need to re-throw the error
if ( await existing_node.get('type') !== FSNodeContext.TYPE_DIRECTORY ) {
throw error;
}
created_node = existing_node;
} else {
throw error;
}
}
this.created = created_node;
const all_nodes = [
...this.parent_directories_created,
this.created,
];
await Promise.all(all_nodes.map(node => node.awaitStableEntry()));
const response = await this.created.getSafeEntry();
response.parent_dirs_created = [];
for ( const node of this.parent_directories_created ) {
response.parent_dirs_created.push(await node.getSafeEntry());
}
response.requested_path = values.path;
return response;
}
async _create_parents ({ parent_node }) {
const { context, values } = this;
const { _path } = this.modules;
const fs = context.get('services').get('filesystem');
// Determine the deepest existing node
let deepest_existing = parent_node;
let remaining_path = _path.dirname(values.path).split('/').filter(Boolean);
{
const parts = remaining_path.slice();
for ( ;; ) {
if ( remaining_path.length === 0 ) {
return deepest_existing;
}
const component = remaining_path[0];
const next_selector = new NodeChildSelector(deepest_existing.selector, component);
const next_node = await fs.node(next_selector);
if ( ! await next_node.exists() ) {
break;
}
deepest_existing = next_node;
remaining_path.shift();
}
}
const tree_op = new MkTree();
await tree_op.run({
parent: deepest_existing,
tree: [remaining_path.join('/')],
});
this.parent_directories_created = tree_op.directories_created;
return tree_op.leaves[0];
}
/**
* Creates a directory and all its ancestors.
*
* @param {FSNodeContext} dir - The directory to create.
* @returns {Promise} The created directory.
*/
async _create_dir (dir) {
if ( await dir.exists() ) {
if ( ! dir.entry.is_dir ) {
throw APIError.create('dest_is_not_a_directory');
}
return dir;
}
const maybe_path_selector =
dir.get_selector_of_type(NodePathSelector);
if ( ! maybe_path_selector ) {
throw APIError.create('dest_does_not_exist', null, { what_dest: 'path from selector' });
}
const path = maybe_path_selector.value;
const fs = this.context.get('services').get('filesystem');
const tree_op = new MkTree();
await tree_op.run({
parent: await fs.node(new RootNodeSelector()),
tree: [path],
});
return tree_op.leaves[0];
}
async _get_existing_top_parent ({ top_parent }) {
if ( ! await top_parent.exists() ) {
throw APIError.create('dest_does_not_exist', null, {
// This seems verbose, but is necessary information when creating
// shortcuts, otherwise the developer doesn't know if we're talking
// about the shortcut's target directory or this parent directory.
what_dest: 'parent directory of the new directory being created',
});
}
if ( ! top_parent.entry.is_dir ) {
throw APIError.create('dest_is_not_a_directory');
}
return top_parent;
}
}
module.exports = {
QuickMkdir,
HLMkdir,
MkTree,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_mklink.js
================================================
/*
* 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 .
*/
const FSNodeParam = require('../../api/filesystem/FSNodeParam');
const StringParam = require('../../api/filesystem/StringParam');
const { HLFilesystemOperation } = require('./definitions');
const APIError = require('../../api/APIError');
const { TYPE_DIRECTORY } = require('../FSNodeContext');
class HLMkLink extends HLFilesystemOperation {
static PARAMETERS = {
parent: new FSNodeParam('symlink'),
name: new StringParam('name'),
target: new StringParam('target'),
};
static MODULES = {
path: require('node:path'),
};
async _run () {
const { context, values } = this;
const fs = context.get('services').get('filesystem');
const { target, parent, user } = values;
let { name } = values;
if ( ! name ) {
throw APIError.create('field_empty', null, { key: 'name' });
}
if ( ! await parent.exists() ) {
throw APIError.create('dest_does_not_exist');
}
if ( await parent.get('type') !== TYPE_DIRECTORY ) {
throw APIError.create('dest_is_not_a_directory');
}
{
const dest = await parent.getChild(name);
if ( await dest.exists() ) {
throw APIError.create('item_with_same_name_exists', null, {
entry_name: name,
});
}
}
const created = await fs.mklink({
target,
parent,
name,
user,
});
await created.awaitStableEntry();
return await created.getSafeEntry();
}
}
module.exports = {
HLMkLink,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_mkshortcut.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const FSNodeParam = require('../../api/filesystem/FSNodeParam');
const FlagParam = require('../../api/filesystem/FlagParam');
const StringParam = require('../../api/filesystem/StringParam');
const { TYPE_DIRECTORY } = require('../FSNodeContext');
const { HLFilesystemOperation } = require('./definitions');
class HLMkShortcut extends HLFilesystemOperation {
static PARAMETERS = {
parent: new FSNodeParam('shortcut'),
name: new StringParam('name'),
target: new FSNodeParam('target'),
dedupe_name: new FlagParam('dedupe_name', { optional: true }),
};
static MODULES = {
path: require('node:path'),
};
async _run () {
const { context, values } = this;
const fs = context.get('services').get('filesystem');
const { target, parent, user, actor } = values;
let { name, dedupe_name } = values;
if ( ! await target.exists() ) {
throw APIError.create('shortcut_to_does_not_exist');
}
if ( ! name ) {
dedupe_name = true;
name = `Shortcut to ${ await target.get('name')}`;
}
{
const svc_acl = context.get('services').get('acl');
if ( ! await svc_acl.check(actor, target, 'read') ) {
throw await svc_acl.get_safe_acl_error(actor, target, 'read');
}
}
if ( ! await parent.exists() ) {
throw APIError.create('dest_does_not_exist');
}
if ( await parent.get('type') !== TYPE_DIRECTORY ) {
throw APIError.create('dest_is_not_a_directory');
}
{
const dest = await parent.getChild(name);
if ( await dest.exists() ) {
if ( ! dedupe_name ) {
throw APIError.create('item_with_same_name_exists', null, {
entry_name: name,
});
}
const name_ext = this.modules.path.extname(name);
const name_noext = this.modules.path.basename(name, name_ext);
for ( let i = 1 ;; i++ ) {
const try_new_name = `${name_noext} (${i})${name_ext}`;
const try_dest = await parent.getChild(try_new_name);
if ( ! await try_dest.exists() ) {
name = try_new_name;
break;
}
}
}
}
const created = await fs.mkshortcut({
target,
parent,
name,
user,
});
await created.awaitStableEntry();
return await created.getSafeEntry();
}
}
module.exports = {
HLMkShortcut,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_move.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { chkperm, validate_fsentry_name, is_ancestor_of, df, get_user } = require('../../helpers');
const { LLMove } = require('../ll_operations/ll_move');
const { RootNodeSelector } = require('../node/selectors');
const { HLFilesystemOperation } = require('./definitions');
const { MkTree } = require('./hl_mkdir');
const { HLRemove } = require('./hl_remove');
const { TYPE_DIRECTORY } = require('../FSNodeContext');
class HLMove extends HLFilesystemOperation {
static MODULES = {
_path: require('path'),
};
static PROPERTIES = {
parent_directories_created: () => [],
};
async _run () {
const { _path } = this.modules;
const { context, values } = this;
const svc = context.get('services');
const fs = svc.get('filesystem');
const new_metadata = typeof values.new_metadata === 'string'
? values.new_metadata : JSON.stringify(values.new_metadata);
// !! new_name, create_missing_parents, overwrite, dedupe_name
let parent = values.destination_or_parent;
let dest = null;
const source = values.source;
if ( await source.get('is-root') ) {
throw APIError.create('immutable');
}
if ( await parent.get('is-root') ) {
throw APIError.create('cannot_copy_to_root');
}
if ( ! await source.exists() ) {
throw APIError.create('source_does_not_exist');
}
if ( ! await chkperm(source.entry, values.user.id, 'cp') ) {
throw APIError.create('forbidden');
}
if ( source.entry.immutable ) {
throw APIError.create('immutable');
}
// If the "parent" is a file, then it's actually our destination; not the parent.
if ( !values.new_name && await parent.exists() && await parent.get('type') !== TYPE_DIRECTORY ) {
dest = parent;
parent = await dest.getParent();
}
if ( ! await parent.exists() ) {
if ( !parent.path || !values.create_missing_parents ) {
throw APIError.create('dest_does_not_exist');
}
const tree_op = new MkTree();
await tree_op.run({
parent: await fs.node(new RootNodeSelector()),
tree: [parent.path],
});
this.parent_directories_created = tree_op.directories_created;
parent = tree_op.leaves[0];
}
await parent.fetchEntry();
if ( ! await chkperm(parent.entry, values.user.id, 'write') ) {
throw APIError.create('forbidden');
}
if ( await parent.get('type') !== TYPE_DIRECTORY ) {
throw APIError.create('dest_is_not_a_directory');
}
let source_user, dest_user;
// 3. Verify cross-user size constraints
const src_user_id = await source.get('user_id');
const parent_user_id = await parent.get('user_id');
if ( src_user_id !== parent_user_id ) {
source_user = await get_user({ id: src_user_id });
if ( source_user.id !== parent_user_id )
{
dest_user = await get_user({ id: parent_user_id });
}
else
{
dest_user = source_user;
}
await source.fetchSize();
const item_size = source.entry.size;
const sizeService = svc.get('sizeService');
const capacity = await sizeService.get_storage_capacity(dest_user.id);
if ( capacity - await df(dest_user.id) - item_size < 0 ) {
throw APIError.create('storage_limit_reached');
}
}
let target_name = values.new_name ?? await source.get('name');
const metadata = new_metadata ?? await source.get('metadata');
try {
validate_fsentry_name(target_name);
} catch (e) {
throw APIError.create(400, e);
}
if ( dest === null ) {
dest = await parent.getChild(target_name);
}
const src_uid = await source.get('uid');
// const dst_uid = await dest.get('uid');
const par_uid = await parent.get('uid');
if ( src_uid === par_uid ) {
throw APIError.create('source_and_dest_are_the_same');
}
if ( await is_ancestor_of(src_uid, par_uid) ) {
throw APIError('cannot_move_item_into_itself');
}
let overwritten;
if ( await dest.exists() ) {
if ( !values.overwrite && !values.dedupe_name ) {
throw APIError.create('item_with_same_name_exists', null, {
entry_name: await dest.get('name'),
});
}
if ( values.dedupe_name ) {
const target_ext = _path.extname(target_name);
const target_noext = _path.basename(target_name, target_ext);
for ( let i = 1 ;; i++ ) {
const try_new_name = `${target_noext} (${i})${target_ext}`;
const exists = await parent.hasChild(try_new_name);
if ( ! exists ) {
target_name = try_new_name;
break;
}
}
dest = await parent.getChild(target_name);
}
else if ( values.overwrite ) {
overwritten = await dest.getSafeEntry();
const hl_remove = new HLRemove();
await hl_remove.run({
target: dest,
user: values.user,
});
}
else {
throw new Error('unreachable');
}
}
const old_path = await source.get('path');
const ll_move = new LLMove();
const source_new = await ll_move.run({
source,
parent,
target_name,
user: values.user,
metadata: metadata,
});
await source_new.awaitStableEntry();
await source_new.fetchSuggestedApps();
await source_new.fetchOwner();
const response = {
moved: await source_new.getSafeEntry({ thumbnail: true }),
overwritten,
old_path,
};
response.parent_dirs_created = [];
for ( const node of this.parent_directories_created ) {
response.parent_dirs_created.push(await node.getSafeEntry());
}
return response;
}
}
module.exports = {
HLMove,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_name_search.js
================================================
/*
* 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 .
*/
const { DB_READ } = require('../../services/database/consts');
const { Context } = require('../../util/context');
const { NodeUIDSelector } = require('../node/selectors');
const { HLFilesystemOperation } = require('./definitions');
class HLNameSearch extends HLFilesystemOperation {
async _run () {
let { actor, term } = this.values;
const services = Context.get('services');
const svc_fs = services.get('filesystem');
const db = services.get('database')
.get(DB_READ, 'fs.namesearch');
term = term.replace(/%/g, '');
term = `%${ term }%`;
// Only user actors can do this, because the permission
// system would otherwise slow things down
if ( ! actor.type.user ) return [];
const results = await db.read('SELECT uuid FROM fsentries WHERE name LIKE ? AND ' +
'user_id = ? LIMIT 50',
[term, actor.type.user.id]);
const uuids = results.map(v => v.uuid);
const fsnodes = await Promise.all(uuids.map(async uuid => {
return await svc_fs.node(new NodeUIDSelector(uuid));
}));
return Promise.all(fsnodes.map(async fsnode => {
return await fsnode.getSafeEntry();
}));
}
}
module.exports = {
HLNameSearch,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_read.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { LLRead } = require('../ll_operations/ll_read');
const { HLFilesystemOperation } = require('./definitions');
class HLRead extends HLFilesystemOperation {
static CONCERN = 'filesystem';
static MODULES = {
'stream': require('stream'),
};
async _run () {
const {
fsNode, actor,
line_count, byte_count,
offset,
version_id, range,
} = this.values;
if ( ! await fsNode.exists() ) {
throw APIError.create('subject_does_not_exist');
}
const ll_read = new LLRead();
let stream = await ll_read.run({
fsNode,
actor,
version_id,
range,
...(byte_count !== undefined ? {
offset: offset ?? 0,
length: byte_count,
} : {}),
});
if ( line_count !== undefined ) {
stream = this._wrap_stream_line_count(stream, line_count);
}
return stream;
}
/**
* returns a new stream that will only produce the first `line_count` lines
* @param {*} stream - input stream
* @param {*} line_count - number of lines to produce
*/
_wrap_stream_line_count (stream, line_count) {
const readline = require('readline');
const rl = readline.createInterface({
input: stream,
terminal: false,
});
const { PassThrough } = this.modules.stream;
const output_stream = new PassThrough();
let lines_read = 0;
new Promise((resolve, reject) => {
rl.on('line', (line) => {
if ( lines_read++ >= line_count ) {
return rl.close();
}
output_stream.write(lines_read > 1 ? `\r\n${ line}` : line);
});
rl.on('error', () => {
console.log('error');
});
rl.on('close', function () {
resolve();
});
});
return output_stream;
}
}
module.exports = {
HLRead,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_readdir.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { Context } = require('../../util/context');
const { get_apps, suggestedAppsForFsEntries } = require('../../helpers');
const { ECMAP } = require('../ECMAP');
const { TYPE_DIRECTORY, TYPE_SYMLINK } = require('../FSNodeContext');
const { LLListUsers } = require('../ll_operations/ll_listusers');
const { LLReadDir } = require('../ll_operations/ll_readdir');
const { LLReadShares } = require('../ll_operations/ll_readshares');
const { HLFilesystemOperation } = require('./definitions');
const { DB_READ } = require('../../services/database/consts');
const config = require('../../config');
class HLReadDir extends HLFilesystemOperation {
static CONCERN = 'filesystem';
async _run () {
return ECMAP.arun(async () => {
const ecmap = Context.get(ECMAP.SYMBOL);
ecmap.store_fsNodeContext(this.values.subject);
return await this.__run();
});
}
async __run () {
const { subject: subject_let, user, no_thumbs, no_assocs, no_subdomains, actor } = this.values;
let subject = subject_let;
if ( ! await subject.exists() ) {
throw APIError.create('subject_does_not_exist');
}
if ( await subject.get('type') === TYPE_SYMLINK ) {
const { context } = this;
const svc_acl = context.get('services').get('acl');
if ( ! await svc_acl.check(actor, subject, 'read') ) {
throw await svc_acl.get_safe_acl_error(actor, subject, 'read');
}
const target = await subject.getTarget();
subject = target;
}
if ( await subject.get('type') !== TYPE_DIRECTORY ) {
const { context } = this;
const svc_acl = context.get('services').get('acl');
if ( ! await svc_acl.check(actor, subject, 'see') ) {
throw await svc_acl.get_safe_acl_error(actor, subject, 'see');
}
throw APIError.create('readdir_of_non_directory');
}
let children;
this.log.debug(
'READDIR',
{
userdir: await subject.isUserDirectory(),
namediff: await subject.get('name') !== user.username,
},
);
if ( subject.isRoot ) {
const ll_listusers = new LLListUsers();
children = await ll_listusers.run(this.values);
} else if (
await subject.getUserPart() !== user.username &&
await subject.isUserDirectory()
) {
const ll_readshares = new LLReadShares();
children = await ll_readshares.run(this.values);
} else {
const ll_readdir = new LLReadDir();
children = await ll_readdir.run(this.values);
}
const associated_app_specifiers = [];
const children_with_assoc = [];
await Promise.all(children.map(async child => {
if ( ! no_thumbs ) {
await child.fetchEntry({ thumbnail: true });
} else {
await child.fetchEntry();
}
const assoc_id = child.entry?.associated_app_id;
if ( assoc_id ) {
associated_app_specifiers.push({ id: assoc_id });
children_with_assoc.push({ child, assoc_id });
}
}));
if ( associated_app_specifiers.length ) {
const assoc_apps = await get_apps(associated_app_specifiers);
const app_by_id = new Map();
for ( let i = 0; i < associated_app_specifiers.length; i++ ) {
const app = assoc_apps[i];
if ( app ) {
app_by_id.set(associated_app_specifiers[i].id, app);
}
}
for ( const { child, assoc_id } of children_with_assoc ) {
const app = app_by_id.get(assoc_id);
if ( app ) {
child.entry.associated_app = app;
}
}
}
if ( ! no_assocs ) {
await this.#batchFetchSuggestedApps(children, user);
}
if ( ! no_subdomains ) {
// await this.#batchFetchSubdomains(children, user);
await this.#applySubdomains(children);
}
return Promise.all(children.map(async child => {
const entry = await child.getSafeEntry();
if ( !no_thumbs && entry.associated_app ) {
const svc_appIcon = this.context.get('services').get('app-icon');
const iconPath = svc_appIcon.getAppIconPath({
appUid: entry.associated_app.uid ?? entry.associated_app.uuid,
size: 64,
});
if ( iconPath ) {
entry.associated_app.icon = iconPath;
}
}
return entry;
}));
}
async #applySubdomains (children) {
for ( const child of children ) {
if ( ! child.subdomains ) return;
if ( child.subdomains.length > 0 ) child.has_website = true;
for ( const subdomain of child.subdomains ) {
subdomain.address =
`${config.protocol}://${subdomain.subdomain}.puter.site`;
}
}
}
async #batchFetchSubdomains (children, user) {
const childIds = [];
const childById = new Map();
for ( const child of children ) {
const entry = child.entry;
if ( ! entry ) continue;
entry.subdomains = [];
entry.workers = [];
if ( entry.id == null ) continue;
childIds.push(entry.id);
childById.set(entry.id, child);
}
if ( childIds.length === 0 ) return;
const placeholders = childIds.map(() => '?').join(',');
const db = this.context.get('services').get('database').get(DB_READ, 'filesystem');
const rows = await db.read(
`SELECT root_dir_id, subdomain, uuid
FROM subdomains
WHERE root_dir_id IN (${placeholders}) AND user_id = ?`,
[...childIds, user.id],
);
for ( const row of rows ) {
const child = childById.get(row.root_dir_id);
if ( ! child ) continue;
if ( child.entry.is_dir ) {
child.entry.subdomains.push({
subdomain: row.subdomain,
address: `${config.protocol }://${ row.subdomain }.puter.site`,
uuid: row.uuid,
});
} else {
const workerName = row.subdomain.split('.').pop();
child.entry.workers.push({
subdomain: workerName,
address: `https://${ workerName }.puter.work`,
uuid: row.uuid,
});
}
child.entry.has_website = true;
}
}
async #batchFetchSuggestedApps (children, user) {
const entries = [];
const targets = [];
for ( const child of children ) {
const entry = child.entry;
if ( !entry || entry.suggested_apps ) continue;
entries.push(entry);
targets.push(entry);
}
if ( entries.length === 0 ) return;
const suggestedLists = await suggestedAppsForFsEntries(entries, { user });
for ( let index = 0; index < targets.length; index++ ) {
targets[index].suggested_apps = suggestedLists[index] ?? [];
}
}
}
module.exports = {
HLReadDir,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_remove.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { chkperm } = require('../../helpers');
const { TYPE_DIRECTORY } = require('../FSNodeContext');
const { LLRmDir } = require('../ll_operations/ll_rmdir');
const { LLRmNode } = require('../ll_operations/ll_rmnode');
const { HLFilesystemOperation } = require('./definitions');
class HLRemove extends HLFilesystemOperation {
static PARAMETERS = {
target: {},
user: {},
recursive: {},
descendants_only: {},
};
async _run () {
const { target, user } = this.values;
if ( ! await target.exists() ) {
throw APIError.create('subject_does_not_exist');
}
if ( ! chkperm(target.entry, user.id, 'rm') ) {
throw APIError.create('forbidden');
}
if ( await target.get('type') === TYPE_DIRECTORY ) {
const ll_rmdir = new LLRmDir();
return await ll_rmdir.run(this.values);
}
const ll_rmnode = new LLRmNode();
return await ll_rmnode.run(this.values);
}
}
module.exports = {
HLRemove,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_stat.js
================================================
/*
* 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 .
*/
const { Context } = require('../../util/context');
const { HLFilesystemOperation } = require('./definitions');
const APIError = require('../../api/APIError');
const { ECMAP } = require('../ECMAP');
const { NodeUIDSelector } = require('../node/selectors');
class HLStat extends HLFilesystemOperation {
static MODULES = {
'mime-types': require('mime-types'),
};
async _run () {
return await ECMAP.arun(async () => {
const ecmap = Context.get(ECMAP.SYMBOL);
ecmap.store_fsNodeContext(this.values.subject);
return await this.__run();
});
}
// async _run () {
// return await this.__run();
// }
async __run () {
const {
subject, user,
return_subdomains,
return_permissions, // Deprecated: kept for backwards compatiable with `return_shares`
return_shares,
return_versions,
return_size,
} = this.values;
const maybe_uid_selector = subject.get_selector_of_type(NodeUIDSelector);
// users created before 2025-07-30 might have fsentries with NULL paths.
// we can remove this check once that is fixed.
const user_unix_ts = Number((`${Date.parse(Context.get('actor')?.type?.user?.timestamp)}`).slice(0, -3));
const paths_are_fine = user_unix_ts >= 1722385593;
const do_after_fetchEntry = [];
const do_alongside_fetchEntry = [];
if ( return_size ) {
do_after_fetchEntry.push(async () => {
await subject.fetchSize(user);
});
}
if ( return_subdomains ) {
do_after_fetchEntry.push(async () => {
await subject.fetchSubdomains(user);
});
}
if ( return_shares || return_permissions ) {
do_after_fetchEntry.push(async () => {
await subject.fetchShares();
});
}
if ( return_versions ) {
do_after_fetchEntry.push(async () => {
await subject.fetchVersions();
});
}
do_after_fetchEntry.push(async () => {
await subject.fetchOwner();
}, async () => {
await subject.get('writable');
});
((maybe_uid_selector || paths_are_fine)
? do_alongside_fetchEntry
: do_after_fetchEntry).push(subject.fetchIsEmpty.bind(subject));
// if ( maybe_uid_selector || paths_are_fine ) {
// await Promise.all([
// subject.fetchEntry(),
// subject.fetchIsEmpty(),
// ]);
// } else {
// // We need the entry first in order for is_empty to work correctly
// await subject.fetchEntry();
// await subject.fetchIsEmpty();
// }
await Promise.all([
(async () => {
await subject.fetchEntry();
const context = Context.get();
const svc_acl = context.get('services').get('acl');
const actor = context.get('actor');
if ( ! await svc_acl.check(actor, subject, 'read') ) {
throw await svc_acl.get_safe_acl_error(actor, subject, 'read');
}
if ( ! subject.found ) {
throw APIError.create('subject_does_not_exist');
}
await Promise.all(do_after_fetchEntry.map(f => f()));
})(),
...(do_alongside_fetchEntry.map(f => f())),
]);
// file not found
// await subject.fetchOwner();
// TODO: why is this specific to stat?
const mime = this.require('mime-types');
const contentType = mime.contentType(subject.entry.name);
subject.entry.type = contentType ? contentType : null;
// if ( return_size ) await subject.fetchSize(user);
// if ( return_subdomains ) await subject.fetchSubdomains(user);
// if ( return_shares || return_permissions ) {
// await subject.fetchShares();
// }
// if ( return_versions ) await subject.fetchVersions();
return await subject.getSafeEntry();
}
}
module.exports = {
HLStat,
};
================================================
FILE: src/backend/src/filesystem/hl_operations/hl_write.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const FSNodeParam = require('../../api/filesystem/FSNodeParam');
const FlagParam = require('../../api/filesystem/FlagParam');
const StringParam = require('../../api/filesystem/StringParam');
const UserParam = require('../../api/filesystem/UserParam');
const config = require('../../config');
const { chkperm, validate_fsentry_name } = require('../../helpers');
const { TeePromise } = require('@heyputer/putility').libs.promise;
const { offset_write_stream } = require('../../util/streamutil');
const { TYPE_DIRECTORY } = require('../FSNodeContext');
const { LLRead } = require('../ll_operations/ll_read');
const { RootNodeSelector, NodePathSelector } = require('../node/selectors');
const { is_valid_node_name } = require('../validation');
const { HLFilesystemOperation } = require('./definitions');
const { MkTree } = require('./hl_mkdir');
const { Actor } = require('../../services/auth/Actor');
const { LLCWrite, LLOWrite } = require('../ll_operations/ll_write');
// 2 MiB limit for client-provided thumbnails
const MAX_THUMBNAIL_SIZE = 2 * 1024 * 1024;
class WriteCommonFeature {
install_in_instance (instance) {
instance._verify_size = async function () {
if (
this.values.file &&
this.values.file.size > config.max_file_size
) {
throw APIError.create('file_too_large', null, {
max_size: config.max_file_size,
});
}
if (
this.values.thumbnail &&
typeof this.values.thumbnail === 'string'
) {
const RATIO = 4 / 3; // 4 bytes per 3 base64 characters
const decoded_size = Math.ceil(this.values.thumbnail.length * RATIO);
if ( decoded_size > MAX_THUMBNAIL_SIZE ) {
throw APIError.create('thumbnail_too_large', null, {
max_size: MAX_THUMBNAIL_SIZE,
});
}
}
// configured thumbnail size limit (can be lower than MAX_THUMBNAIL_SIZE)
if (
this.values.thumbnail &&
this.values.thumbnail.size > config.max_thumbnail_size
) {
throw APIError.create('thumbnail_too_large', null, {
max_size: config.max_thumbnail_size,
});
}
};
instance._verify_room = async function () {
if ( ! this.values.file ) return;
const sizeService = this.context.get('services').get('sizeService');
const { file, user: user_let } = this.values;
let user = user_let;
if ( ! user ) user = this.values.actor.type.user;
const usage = await sizeService.get_usage(user.id);
const capacity = await sizeService.get_storage_capacity(user.id);
if ( capacity - usage - file.size < 0 ) {
throw APIError.create('storage_limit_reached');
}
};
}
}
class HLWrite extends HLFilesystemOperation {
static DESCRIPTION = `
High-level write operation.
This operation is a wrapper around the low-level write operation.
It provides the following features:
- create missing parent directories
- overwrite existing files
- deduplicate files with the same name
- accept client-provided thumbnails
- create shortcuts
`;
static FEATURES = [
new WriteCommonFeature(),
];
static PARAMETERS = {
// the parent directory, or a filepath that doesn't exist yet
destination_or_parent: new FSNodeParam('path'),
// if specified, destination_or_parent must be a directory
specified_name: new StringParam('specified_name', { optional: true }),
// used if specified_name is undefined and destination_or_parent is a directory
// NB: if destination_or_parent does not exist and create_missing_parents
// is true then destination_or_parent will be a directory
fallback_name: new StringParam('fallback_name', { optional: true }),
overwrite: new FlagParam('overwrite', { optional: true }),
dedupe_name: new FlagParam('dedupe_name', { optional: true }),
// other options
shortcut_to: new FSNodeParam('shortcut_to', { optional: true }),
create_missing_parents: new FlagParam('create_missing_parents', { optional: true }),
user: new UserParam(),
// client-provided thumbnail as a base64 string
thumbnail: new StringParam('thumbnail', { optional: true }),
// file: multer.File
};
static MODULES = {
_path: require('path'),
};
async _run () {
const { context, values } = this;
const { _path } = this.modules;
const fs = context.get('services').get('filesystem');
const svc_event = context.get('services').get('event');
let parent = values.destination_or_parent;
let destination = null;
await this._verify_size();
await this._verify_room();
this.checkpoint('before parent exists check');
if ( !await parent.exists() && values.create_missing_parents ) {
if ( ! (parent.selector instanceof NodePathSelector) ) {
throw APIError.create('dest_does_not_exist', null, {
parent: parent.selector,
});
}
const path = parent.selector.value;
const tree_op = new MkTree();
await tree_op.run({
parent: await fs.node(new RootNodeSelector()),
tree: [path],
});
parent = await fs.node(new NodePathSelector(path));
const parent_exists_now = await parent.exists();
if ( ! parent_exists_now ) {
this.log.error('FAILED TO CREATE DESTINATION');
throw APIError.create('dest_does_not_exist', null, {
parent: parent.selector,
});
}
}
if ( parent.isRoot ) {
throw APIError.create('cannot_write_to_root');
}
let target_name = values.specified_name || values.fallback_name;
// If a name is specified then the destination must be a directory
if ( values.specified_name ) {
this.checkpoint('specified name condition');
if ( ! await parent.exists() ) {
throw APIError.create('dest_does_not_exist');
}
if ( await parent.get('type') !== TYPE_DIRECTORY ) {
throw APIError.create('dest_is_not_a_directory');
}
target_name = values.specified_name;
}
this.checkpoint('check parent DNE or is not a directory');
if (
!await parent.exists() ||
await parent.get('type') !== TYPE_DIRECTORY
) {
destination = parent;
parent = await destination.getParent();
target_name = destination.name;
}
if ( parent.isRoot ) {
throw APIError.create('cannot_write_to_root');
}
try {
// old validator is kept here to avoid changing the
// error messages; eventually is_valid_node_name
// will support more detailed error reporting
validate_fsentry_name(target_name);
if ( ! is_valid_node_name(target_name) ) {
throw { message: 'invalid node name' };
}
} catch (e) {
throw APIError.create('invalid_file_name', null, {
name: target_name,
reason: e.message,
});
}
if ( ! destination ) {
destination = await parent.getChild(target_name);
}
let is_overwrite = false;
// TODO: Gotta come up with a reasonable guideline for if/when we put
// object members in the scope; it feels too arbitrary right now.
const { overwrite, dedupe_name } = values;
this.checkpoint('before overwrite behaviours');
const dest_exists = await destination.exists();
if ( values.offset !== undefined && !dest_exists ) {
throw APIError.create('offset_without_existing_file');
}
// The correct ACL check here depends on context.
// ll_write checks ACL, but we need to shortcut it here
// or else we might send the user too much information.
{
const node_to_check =
( dest_exists && overwrite && !dedupe_name )
? destination : parent;
const actor = values.actor ?? Actor.adapt(values.user);
const svc_acl = context.get('services').get('acl');
if ( ! await svc_acl.check(actor, node_to_check, 'write') ) {
throw await svc_acl.get_safe_acl_error(actor, node_to_check, 'write');
}
}
if ( dest_exists ) {
if ( !overwrite && !dedupe_name ) {
throw APIError.create('item_with_same_name_exists', null, {
entry_name: target_name,
});
}
if ( dedupe_name ) {
const target_ext = _path.extname(target_name);
const target_noext = _path.basename(target_name, target_ext);
for ( let i = 1 ;; i++ ) {
const try_new_name = `${target_noext} (${i})${target_ext}`;
const exists = await parent.hasChild(try_new_name);
if ( ! exists ) {
target_name = try_new_name;
break;
}
}
destination = await parent.getChild(target_name);
}
else if ( overwrite ) {
if ( await destination.get('immutable') ) {
throw APIError.create('immutable');
}
if ( await destination.get('type') === TYPE_DIRECTORY ) {
throw APIError.create('cannot_overwrite_a_directory');
}
is_overwrite = true;
}
}
if ( values.shortcut_to ) {
this.checkpoint('shortcut condition');
const shortcut_to = values.shortcut_to;
if ( ! await shortcut_to.exists() ) {
throw APIError.create('shortcut_to_does_not_exist');
}
if ( await shortcut_to.get('type') === TYPE_DIRECTORY ) {
throw APIError.create('shortcut_target_is_a_directory');
}
// TODO: legacy check - likely not needed
const has_perm = await chkperm(shortcut_to.entry, values.actor.type.user.id, 'read');
if ( ! has_perm ) throw APIError.create('permission_denied');
this.created = await fs.mkshortcut({
parent,
name: target_name,
actor: values.actor,
target: shortcut_to,
});
await this.created.awaitStableEntry();
await this.created.fetchEntry({ thumbnail: true });
return await this.created.getSafeEntry();
}
this.checkpoint('before thumbnail');
let thumbnail_promise = new TeePromise();
if ( await parent.isAppDataDirectory() || values.no_thumbnail || !values.thumbnail ) {
thumbnail_promise.resolve(undefined);
} else {
// Allow extensions to transform client-provided thumbnails before DB write.
const thumbnailData = { url: values.thumbnail };
await svc_event.emit('thumbnail.created', thumbnailData);
thumbnail_promise.resolve(thumbnailData.url);
}
this.checkpoint('before delegate');
if ( values.offset !== undefined ) {
if ( ! is_overwrite ) {
throw APIError.create('offset_requires_overwrite');
}
if ( ! values.file.stream ) {
throw APIError.create('offset_requires_stream');
}
const replace_length = values.file.size;
let dst_size = await destination.get('size');
if ( values.offset > dst_size ) {
values.offset = dst_size;
}
if ( values.offset + values.file.size > dst_size ) {
dst_size = values.offset + values.file.size;
}
const ll_read = new LLRead();
const read_stream = await ll_read.run({
fsNode: destination,
});
values.file.stream = offset_write_stream({
originalDataStream: read_stream,
newDataStream: values.file.stream,
offset: values.offset,
replace_length,
});
values.file.size = dst_size;
}
if ( is_overwrite ) {
const ll_owrite = new LLOWrite();
this.written = await ll_owrite.run({
node: destination,
actor: values.actor,
file: values.file,
tmp: {
socket_id: values.socket_id,
operation_id: values.operation_id,
item_upload_id: values.item_upload_id,
},
fsentry_tmp: {
thumbnail_promise,
},
message: values.message,
});
} else {
const ll_cwrite = new LLCWrite();
this.written = await ll_cwrite.run({
parent,
name: target_name,
actor: values.actor,
file: values.file,
tmp: {
socket_id: values.socket_id,
operation_id: values.operation_id,
item_upload_id: values.item_upload_id,
},
fsentry_tmp: {
thumbnail_promise,
},
message: values.message,
app_id: values.app_id,
});
}
this.checkpoint('after delegate');
await this.written.awaitStableEntry();
this.checkpoint('after await stable entry');
const response = await this.written.getSafeEntry({ thumbnail: true });
this.checkpoint('after get safe entry');
return response;
}
}
module.exports = {
HLWrite,
};
================================================
FILE: src/backend/src/filesystem/lib/PuterPath.js
================================================
/*
* 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 .
*/
const _path = require('path');
/**
* Puter paths look like any of the following:
*
* Absolute path: /user/dir1/dir2/file
* From UID: AAAA-BBBB-CCCC-DDDD/../a/b/c
*
* The difference between an absolute path and a UID-relative path
* is the leading forward-slash character.
*/
class PuterPath {
static NULL_UUID = '00000000-0000-0000-0000-000000000000';
static adapt (value) {
if ( value instanceof PuterPath ) return value;
return new PuterPath(value);
}
constructor (text) {
this.text = text;
}
set text (text) {
this.text_ = text.trim();
this.normUnix = _path.normalize(text);
this.normFlat =
(this.normUnix.endsWith('/') && this.normUnix.length > 1)
? this.normUnix.slice(0, -1) : this.normUnix;
}
get text () {
return this.text_;
}
isRoot () {
if ( this.normFlat === '/' ) return true;
if ( this.normFlat === this.constructor.NULL_UUID ) {
return true;
}
return false;
}
isAbsolute () {
return this.text.startsWith('/');
}
isFromUID () {
return !this.isAbsolute();
}
get reference () {
if ( this.isAbsolute ) return this.constructor.NULL_UUID;
return this.text.slice(0, this.text.indexOf('/'));
}
get relativePortion () {
if ( this.isAbsolute() ) {
return this.text.slice(1);
}
if ( ! this.text.includes('/') ) return '';
return this.text.slice(this.text.indexOf('/') + 1);
}
}
module.exports = { PuterPath };
================================================
FILE: src/backend/src/filesystem/ll_operations/definitions.js
================================================
/*
* 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 .
*/
const { BaseOperation } = require('../../services/OperationTraceService');
class LLFilesystemOperation extends BaseOperation {
}
module.exports = {
LLFilesystemOperation,
};
================================================
FILE: src/backend/src/filesystem/ll_operations/ll_copy.js
================================================
/*
* 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 .
*/
const { LLFilesystemOperation } = require('./definitions');
const fsCapabilities = require('../definitions/capabilities');
class LLCopy extends LLFilesystemOperation {
static MODULES = {
_path: require('path'),
uuidv4: require('uuid').v4,
};
async _run () {
const { _path, uuidv4 } = this.modules;
const { context } = this;
const { source, parent, user, actor, target_name } = this.values;
const svc = context.get('services');
const fs = svc.get('filesystem');
const svc_event = svc.get('event');
const uuid = uuidv4();
const ts = Math.round(Date.now() / 1000);
this.field('target-uid', uuid);
this.field('source', source.selector.describe());
this.checkpoint('before fetch parent entry');
await parent.fetchEntry();
this.checkpoint('before fetch source entry');
await source.fetchEntry({ thumbnail: true });
this.checkpoint('fetched source and parent entries');
// Access Control
{
const svc_acl = context.get('services').get('acl');
this.checkpoint('copy :: access control');
// Check read access to source
if ( ! await svc_acl.check(actor, source, 'read') ) {
throw await svc_acl.get_safe_acl_error(actor, source, 'read');
}
// Check write access to destination
if ( ! await svc_acl.check(actor, parent, 'write') ) {
throw await svc_acl.get_safe_acl_error(actor, source, 'write');
}
}
const capabilities = source.provider.get_capabilities();
if ( capabilities.has(fsCapabilities.COPY_TREE) ) {
const result_node = await source.provider.copy_tree({
context,
source,
parent,
target_name,
});
return result_node;
} else {
throw new Error('only copy_tree is current supported by ll_copy');
}
}
}
module.exports = {
LLCopy,
};
================================================
FILE: src/backend/src/filesystem/ll_operations/ll_copy_idea.js
================================================
/*
* 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 .
*/
/*
This file describes an idea to make fine-grained
steps of a filesystem operation more declarative.
This could have advantages like:
- easier tracking of side-effects
- steps automatically mark checkpoints
- steps automatically have tracing
- implications of re-ordering steps would
always be known
- easier to diagnose stuck operations
*/
/* eslint-disable */
const STEPS_COPY_CONTENTS = [
{
id: 'add storage info to fsentry',
behaviour: 'none',
fn: async ({ util, values }) => {
const { source } = values;
// "util.assign" makes it possible to
// track changes caused by this step
util.assign('raw_fsentry', {
size: source.entry.size,
// ...
})
}
},
{
id: 'create progress tracker',
behaviour: 'values',
fn: async () => {
const progress_tracker =
new UploadProgressTracker();
return {
progress_tracker
};
}
},
{
id: 'emit copy progress event',
behaviour: 'side-effect',
fn: async ({ services }) => {
services.event.emit(
/// ...
)
}
},
{
id: 'get storage backend',
behaviour: 'values',
fn: async ({ services }) => {
const storage = new
PuterS3StorageStrategy({
services
})
return { storage };
}
},
// ...
]
const STEPS = [
{
id: 'generate uuid and ts',
behaviour: 'values',
fn: async ({ modules }) => {
return {
uuid: modules.uuidv4(),
ts: Math.round(Date.now()/1000)
};
}
},
{
id: 'redundancy fetch',
behaviour: 'side-effect',
fn: async ({ values }) => {
await values.source.fetchEntry({
thumbnail: true,
});
await values.parent.fetchEntry();
}
},
{
id: 'generate raw fsentry',
behaviour: 'values',
fn: async ({ values }) => {
const {
source,
parent, target_name,
uuid, ts,
user,
} = values;
const raw_fsentry = {
uuid,
is_dir: source.entry.is_dir,
// ...
};
return { raw_fsentry };
}
},
{
id: 'emit fs.pending.file',
fn: () => {
// ...
}
},
{
id: 'copy contents',
cond: async ({ values }) => {
return await values.source.get('has-s3');
},
steps: STEPS_COPY_CONTENTS,
},
// ...
]
class LLCopy extends LLFilesystemOperation {
static STEPS = STEPS
}
================================================
FILE: src/backend/src/filesystem/ll_operations/ll_listusers.js
================================================
/*
* 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 .
*/
const { RootNodeSelector, NodeChildSelector } = require('../node/selectors');
const { LLFilesystemOperation } = require('./definitions');
class LLListUsers extends LLFilesystemOperation {
static description = `
List user directories which are relevant to the
current actor.
`;
async _run () {
const { context } = this;
const svc = context.get('services');
const svc_permission = svc.get('permission');
const svc_fs = svc.get('filesystem');
const user = this.values.user;
const issuers = await svc_permission.list_user_permission_issuers(user);
const nodes = [];
nodes.push(await svc_fs.node(new NodeChildSelector(new RootNodeSelector(),
user.username)));
for ( const issuer of issuers ) {
const node = await svc_fs.node(new NodeChildSelector(new RootNodeSelector(),
issuer.username));
nodes.push(node);
}
return nodes;
}
}
module.exports = {
LLListUsers,
};
================================================
FILE: src/backend/src/filesystem/ll_operations/ll_mkdir.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { MODE_WRITE } = require('../../services/fs/FSLockService');
const { NodeUIDSelector, NodeChildSelector } = require('../node/selectors');
const { RESOURCE_STATUS_PENDING_CREATE } = require('../../modules/puterfs/ResourceService');
const { LLFilesystemOperation } = require('./definitions');
class LLMkdir extends LLFilesystemOperation {
static CONCERN = 'filesystem';
static MODULES = {
_path: require('path'),
uuidv4: require('uuid').v4,
};
async _run () {
const { parent, name, immutable } = this.values;
const actor = this.values.actor ?? this.context.get('actor');
const services = this.context.get('services');
const svc_fsLock = services.get('fslock');
const svc_acl = services.get('acl');
/* eslint-disable */ // -- Please fix this linter rule
const lock_handle = await svc_fsLock.lock_child(
await parent.get('path'),
name,
MODE_WRITE,
);
/* eslint-enable */
try {
if ( ! await svc_acl.check(actor, parent, 'write') ) {
throw await svc_acl.get_safe_acl_error(actor, parent, 'write');
}
return await parent.provider.mkdir({
actor,
context: this.context,
parent,
name,
immutable,
});
} finally {
lock_handle.unlock();
}
}
}
module.exports = {
LLMkdir,
};
================================================
FILE: src/backend/src/filesystem/ll_operations/ll_move.js
================================================
/*
* 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 .
*/
const { LLFilesystemOperation } = require('./definitions');
class LLMove extends LLFilesystemOperation {
static MODULES = {
_path: require('path'),
};
async _run () {
const { context } = this;
const { source, parent, actor, target_name, metadata } = this.values;
// Access Control
{
const svc_acl = context.get('services').get('acl');
this.checkpoint('move :: access control');
// Check write access to source
if ( ! await svc_acl.check(actor, source, 'write') ) {
throw await svc_acl.get_safe_acl_error(actor, source, 'write');
}
// Check write access to destination
if ( ! await svc_acl.check(actor, parent, 'write') ) {
throw await svc_acl.get_safe_acl_error(actor, parent, 'write');
}
}
await source.provider.move({
context: this.context,
node: source,
new_parent: parent,
new_name: target_name,
metadata,
});
return source;
}
}
module.exports = {
LLMove,
};
================================================
FILE: src/backend/src/filesystem/ll_operations/ll_read.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { get_user } = require('../../helpers');
const { UserActorType } = require('../../services/auth/Actor');
const { Actor } = require('../../services/auth/Actor');
const { DB_WRITE } = require('../../services/database/consts');
const { Context } = require('../../util/context');
const { TYPE_SYMLINK, TYPE_DIRECTORY } = require('../FSNodeContext');
const { LLFilesystemOperation } = require('./definitions');
const checkACLForRead = async (aclService, actor, fsNode, skip = false) => {
if ( skip ) {
return;
}
if ( ! await aclService.check(actor, fsNode, 'read') ) {
throw await aclService.get_safe_acl_error(actor, fsNode, 'read');
}
};
const typeCheckForRead = async (fsNode) => {
if ( await fsNode.get('type') === TYPE_DIRECTORY ) {
throw APIError.create('cannot_read_a_directory');
}
};
class LLRead extends LLFilesystemOperation {
static CONCERN = 'filesystem';
async _run ({ fsNode, no_acl, actor, offset, length, range, version_id } = {}) {
// extract services from context
const aclService = Context.get('services').get('acl');
const db = Context.get('services')
.get('database').get(DB_WRITE, 'filesystem');
// validate input
if ( ! await fsNode.exists() ) {
throw APIError.create('subject_does_not_exist');
}
// validate initial node
await checkACLForRead(aclService, actor, fsNode, no_acl);
await typeCheckForRead(fsNode);
let type = await fsNode.get('type');
let traversedCount = 0;
while ( type === TYPE_SYMLINK ) {
fsNode = await fsNode.getTarget();
type = await fsNode.get('type');
traversedCount++;
}
// validate symlink leaf node
if ( traversedCount > 0 ) {
await checkACLForRead(aclService, actor, fsNode, no_acl);
await typeCheckForRead(fsNode);
}
// calculate range inputs
const has_range = (
offset !== undefined &&
offset !== 0
) || (
length !== undefined &&
length != await fsNode.get('size')
) || range !== undefined;
// timestamp access
db.write('UPDATE `fsentries` SET `accessed` = ? WHERE `id` = ?',
[Date.now() / 1000, await fsNode.get('mysql-id')]);
const ownerId = await fsNode.get('user_id');
const chargedActor = actor ? actor : new Actor({
type: new UserActorType({
user: await get_user({ id: ownerId }),
}),
});
//define metering service
/** @type {import("../../services/MeteringService/MeteringService").MeteringService} */
const meteringService = Context.get('services').get('meteringService').meteringService;
const svc_mountpoint = Context.get('services').get('mountpoint');
const provider = await svc_mountpoint.get_provider(fsNode.selector);
// const storage = svc_mountpoint.get_storage(provider.constructor.name);
// Empty object here is in the case of local fiesystem,
// where s3:location will return null.
// TODO: storage interface shouldn't have S3-specific properties.
// const location = await fsNode.get('s3:location') ?? {};
// const stream = (await storage.create_read_stream(await fsNode.get('uid'), {
// // TODO: fs:decouple-s3
// bucket: location.bucket,
// bucket_region: location.bucket_region,
// version_id,
// key: location.key,
// memory_file: fsNode.entry,
// ...(range ? { range } : (has_range ? {
// range: `bytes=${offset}-${offset + length - 1}`,
// } : {})),
// }));
const stream = await provider.read({
context: this.context,
node: fsNode,
version_id: version_id,
...(range ? { range } : (has_range ? {
range: `bytes=${offset}-${offset + length - 1}`,
} : {})),
});
// Meter ingress
const size = await (async () => {
if ( range ) {
const match = range.match(/bytes=(\d+)-(\d+)/);
if ( match ) {
const start = parseInt(match[1], 10);
const end = parseInt(match[2], 10);
return end - start + 1;
}
}
if ( has_range ) {
return length;
}
return await fsNode.get('size');
})();
meteringService.incrementUsage(chargedActor, 'filesystem:egress:bytes', size);
return stream;
}
}
module.exports = {
LLRead,
};
================================================
FILE: src/backend/src/filesystem/ll_operations/ll_readdir.js
================================================
/*
* 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 .
*/
const fsCapabilities = require('../definitions/capabilities');
const { ECMAP } = require('../ECMAP');
const { TYPE_SYMLINK } = require('../FSNodeContext');
const { RootNodeSelector } = require('../node/selectors');
const { NodeUIDSelector, NodeChildSelector } = require('../node/selectors');
const { LLFilesystemOperation } = require('./definitions');
class LLReadDir extends LLFilesystemOperation {
static CONCERN = 'filesystem';
async _run () {
return ECMAP.arun(async () => {
return await this.__run();
});
}
async __run () {
const { context } = this;
const { subject: subject_let, actor, no_acl } = this.values;
let subject = subject_let;
const svc_acl = context.get('services').get('acl');
if ( ! no_acl ) {
if ( ! await svc_acl.check(actor, subject, 'list') ) {
throw await svc_acl.get_safe_acl_error(actor, subject, 'list');
}
}
// TODO: DRY ACL check here
const subject_type = await subject.get('type');
if ( subject_type === TYPE_SYMLINK ) {
const target = await subject.getTarget();
if ( ! no_acl ) {
if ( ! await svc_acl.check(actor, target, 'list') ) {
throw await svc_acl.get_safe_acl_error(actor, target, 'list');
}
}
subject = target;
}
const svc = context.get('services');
const svc_fs = svc.get('filesystem');
if ( subject.isRoot ) {
if ( ! actor.type.user ) return [];
return [
await svc_fs.node(new NodeChildSelector(new RootNodeSelector(),
actor.type.user.username)),
];
}
const capabilities = subject.provider.get_capabilities();
// Optimization for filesystems that implement it
{
const child_nodes = await this.#try_readdirstatUUID();
if ( child_nodes !== null ) return child_nodes;
}
if ( capabilities.has(fsCapabilities.READDIR_UUID_MODE) ) {
this.checkpoint('readdir uuid mode');
const child_uuids = await subject.provider.readdir({
context,
node: subject,
});
this.checkpoint('after get direct descendants');
const children = await Promise.all(child_uuids.map(async uuid => {
return await svc_fs.node(new NodeUIDSelector(uuid));
}));
this.checkpoint('after get children');
return children;
}
// Conventional Mode
const child_entries = subject.provider.readdir({
context,
node: subject,
});
return await Promise.all(child_entries.map(async entry => {
return await svc_fs.node(new NodeChildSelector(subject, entry.name));
}));
}
async #try_readdirstatUUID () {
const subject = this.values.subject;
const capabilities = subject.provider.get_capabilities();
const uuid_selector = subject.get_selector_of_type(NodeUIDSelector);
// Skip this optimization if there is no UUID
if ( ! uuid_selector ) {
return null;
}
// Skip this optimization if the filesystem doesn't implement
// the "readdirstat_uuid" macro operation.
if ( ! capabilities.has(fsCapabilities.READDIRSTAT_UUID) ) {
return null;
}
const uuid = uuid_selector.value;
return await subject.provider.readdirstat_uuid({
uuid,
options: { thumbnail: true },
});
}
}
module.exports = {
LLReadDir,
};
================================================
FILE: src/backend/src/filesystem/ll_operations/ll_readshares.js
================================================
/*
* 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 .
*/
const { get_user } = require('../../helpers');
const { MANAGE_PERM_PREFIX } = require('../../services/auth/permissionConts.mjs');
const { PermissionUtil } = require('../../services/auth/permissionUtils.mjs');
const { DB_WRITE } = require('../../services/database/consts');
const { NodeUIDSelector } = require('../node/selectors');
const { LLFilesystemOperation } = require('./definitions');
const { LLReadDir } = require('./ll_readdir');
class LLReadShares extends LLFilesystemOperation {
static description = `
Obtain the highest-level entries under this directory
for which the current actor has at least "see" permission.
This is a breadth-first search. When any node is
found with "see" permission is found, children of that node
will not be traversed.
`;
async _run () {
const { subject, user, actor } = this.values;
const svc = this.context.get('services');
const svc_fs = svc.get('filesystem');
const svc_acl = svc.get('acl');
const db = svc.get('database').get(DB_WRITE, 'll_readshares');
const issuer_username = await subject.getUserPart();
const issuer_user = await get_user({ username: issuer_username });
const rows = await db.read('SELECT DISTINCT permission FROM `user_to_user_permissions` ' +
'WHERE `holder_user_id` = ? AND `issuer_user_id` = ? ' +
'AND (`permission` LIKE ? OR `permission` LIKE ?)',
[user.id, issuer_user.id, 'fs:%', 'manage:fs:%']);
const fsentry_uuids = [];
for ( const row of rows ) {
const parts = PermissionUtil.split(row.permission.replace(`${MANAGE_PERM_PREFIX}:`, ''));
fsentry_uuids.push(parts[1]);
}
const results = [];
const ll_readdir = new LLReadDir();
let interm_results = await ll_readdir.run({
subject,
actor,
user,
no_thumbs: true,
no_assocs: true,
no_acl: true,
});
// Clone interm_results in case ll_readdir ever implements caching
interm_results = interm_results.slice();
for ( const fsentry_uuid of fsentry_uuids ) {
const node = await svc_fs.node(new NodeUIDSelector(fsentry_uuid));
if ( ! node ) continue;
interm_results.push(node);
}
for ( const node of interm_results ) {
if ( ! await node.exists() ) continue;
if ( ! await svc_acl.check(actor, node, 'see') ) continue;
results.push(node);
}
return results;
}
}
module.exports = {
LLReadShares,
};
================================================
FILE: src/backend/src/filesystem/ll_operations/ll_rmdir.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { MemoryFSProvider } = require('../../modules/puterfs/customfs/MemoryFSProvider');
const { ParallelTasks, getTracer } = require('../../util/otelutil');
const FSNodeContext = require('../FSNodeContext');
const { NodeUIDSelector } = require('../node/selectors');
const { LLFilesystemOperation } = require('./definitions');
const { LLRmNode } = require('./ll_rmnode');
class LLRmDir extends LLFilesystemOperation {
async _run () {
const {
target,
user,
actor,
descendants_only,
recursive,
// internal use only - not for clients
ignore_not_empty,
max_tasks = 8,
} = this.values;
const { context } = this;
const svc = context.get('services');
// Access Control
{
const svc_acl = context.get('services').get('acl');
this.checkpoint('remove :: access control');
// Check write access to target
if ( ! await svc_acl.check(actor, target, 'write') ) {
throw await svc_acl.get_safe_acl_error(actor, target, 'write');
}
}
if ( await target.get('immutable') && !descendants_only ) {
throw APIError.create('immutable');
}
const fs = svc.get('filesystem');
const children = await target.provider.readdir({
node: target,
});
if ( children.length > 0 && !recursive && !ignore_not_empty ) {
throw APIError.create('not_empty');
}
const tracer = getTracer();
const tasks = new ParallelTasks({ tracer, max: max_tasks });
for ( const child_uuid of children ) {
tasks.add('fs:rm:rm-child', async () => {
const child_node = await fs.node(new NodeUIDSelector(child_uuid));
const type = await child_node.get('type');
if ( type === FSNodeContext.TYPE_DIRECTORY ) {
const ll_rm = new LLRmDir();
await ll_rm.run({
target: await fs.node(new NodeUIDSelector(child_uuid)),
user,
recursive: true,
descendants_only: false,
max_tasks: (v => v > 1 ? v : 1)(Math.floor(max_tasks / 2)),
});
} else {
const ll_rm = new LLRmNode();
await ll_rm.run({
target: await fs.node(new NodeUIDSelector(child_uuid)),
user,
});
}
});
}
await tasks.awaitAll();
// TODO (xiaochen): consolidate these two branches
if ( target.provider instanceof MemoryFSProvider ) {
await target.provider.rmdir({
context,
node: target,
options: {
recursive,
descendants_only,
},
});
} else {
if ( ! descendants_only ) {
await target.provider.rmdir({
context,
node: target,
options: {
ignore_not_empty: true,
},
});
}
}
}
}
module.exports = {
LLRmDir,
};
================================================
FILE: src/backend/src/filesystem/ll_operations/ll_rmnode.js
================================================
/*
* 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 .
*/
const { LLFilesystemOperation } = require('./definitions');
class LLRmNode extends LLFilesystemOperation {
async _run () {
const { target, actor } = this.values;
const { context } = this;
const svc_event = context.get('services').get('event');
// Access Control
{
const svc_acl = context.get('services').get('acl');
this.checkpoint('remove :: access control');
// Check write access to target
if ( ! await svc_acl.check(actor, target, 'write') ) {
throw await svc_acl.get_safe_acl_error(actor, target, 'write');
}
}
await svc_event.emit('fs.remove.node', this.values);
await target.provider.unlink({ context, node: target });
}
}
module.exports = {
LLRmNode,
};
================================================
FILE: src/backend/src/filesystem/ll_operations/ll_write.js
================================================
/*
* 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 .
*/
const { LLFilesystemOperation } = require('./definitions');
const APIError = require('../../api/APIError');
/**
* The "overwrite" write operation.
*
* This operation is used to write a file to an existing path.
*
* @extends LLFilesystemOperation
*/
class LLOWrite extends LLFilesystemOperation {
/**
* Executes the overwrite operation by writing to an existing file node.
* @returns {Promise} Result of the write operation
* @throws {APIError} When the target node does not exist
*/
async _run () {
const node = this.values.node;
// Embed fields into this.context
this.context.set('immutable', this.values.immutable);
this.context.set('tmp', this.values.tmp);
this.context.set('fsentry_tmp', this.values.fsentry_tmp);
this.context.set('message', this.values.message);
this.context.set('actor', this.values.actor);
this.context.set('app_id', this.values.app_id);
// TODO: Add symlink write
if ( ! await node.exists() ) {
// TODO: different class of errors for low-level operations
throw APIError.create('subject_does_not_exist');
}
return await node.provider.write_overwrite({
context: this.context,
node: node,
file: this.values.file,
});
}
}
/**
* The "non-overwrite" write operation.
*
* This operation is used to write a file to a non-existent path.
*
* @extends LLFilesystemOperation
*/
class LLCWrite extends LLFilesystemOperation {
static MODULES = {
_path: require('path'),
uuidv4: require('uuid').v4,
config: require('../../config.js'),
};
/**
* Executes the create operation by writing a new file to the parent directory.
* @returns {Promise} Result of the write operation
* @throws {APIError} When the parent directory does not exist
*/
async _run () {
const parent = this.values.parent;
// Embed fields into this.context
this.context.set('immutable', this.context.get('immutable') ?? this.values.immutable);
this.context.set('tmp', this.context.get('tmp') ?? this.values.tmp);
this.context.set('fsentry_tmp', this.context.get('fsentry_tmp') ?? this.values.fsentry_tmp);
this.context.set('message', this.context.get('message') ?? this.values.message);
this.context.set('actor', this.context.get('actor') ?? this.values.actor);
this.context.set('app_id', this.context.get('app_id') ?? this.values.app_id);
if ( ! await parent.exists() ) {
throw APIError.create('subject_does_not_exist');
}
return await parent.provider.write_new({
context: this.context,
parent,
name: this.values.name,
file: this.values.file,
});
}
}
module.exports = {
LLCWrite,
LLOWrite,
};
================================================
FILE: src/backend/src/filesystem/node/selectors.js
================================================
/*
* 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 .
*/
const _path = require('path');
const { PuterPath } = require('../lib/PuterPath');
/**
* The base class doesn't add any functionality, but it's useful for
* `instanceof` checks.
*/
class NodeSelector {
constructor () {
if ( this.constructor === NodeSelector ) {
throw new Error('cannot instantiate NodeSelector directly; ' +
'that would be like using this: https://devmeme.puter.site/plug.webp');
}
}
}
class NodePathSelector extends NodeSelector {
constructor (path) {
super();
this.value = path;
}
setPropertiesKnownBySelector (node) {
node.path = this.value;
node.name = _path.basename(this.value);
}
describe () {
return this.value;
}
}
class NodeUIDSelector extends NodeSelector {
constructor (uid) {
super();
this.value = uid;
}
setPropertiesKnownBySelector (node) {
node.uid = this.value;
}
// Note: the selector could've been added by FSNodeContext
// during fetch, but this was more efficient because the
// object is created lazily, and it's somtimes not needed.
static implyFromFetchedData (node) {
if ( node.uid ) {
return new NodeUIDSelector(node.uid);
}
return null;
}
describe () {
return `[uid:${this.value}]`;
}
}
class NodeInternalIDSelector extends NodeSelector {
constructor (service, id, debugInfo) {
super();
this.service = service;
this.id = id;
this.debugInfo = debugInfo;
}
setPropertiesKnownBySelector (node) {
if ( this.service === 'mysql' ) {
node.mysql_id = this.id;
}
}
describe (showDebug) {
if ( showDebug ) {
return `[db:${this.id}] (${
JSON.stringify(this.debugInfo, null, 2)
})`;
}
return `[db:${this.id}]`;
}
}
class NodeChildSelector extends NodeSelector {
constructor (parent, name) {
super();
this.parent = parent;
this.name = name;
}
setPropertiesKnownBySelector (node) {
node.name = this.name;
try_infer_attributes(this);
if ( this.path ) {
node.path = this.path;
}
}
describe () {
return `${this.parent.describe() }/${ this.name}`;
}
}
class RootNodeSelector extends NodeSelector {
static entry = {
is_dir: true,
is_root: true,
uuid: PuterPath.NULL_UUID,
name: '/',
};
setPropertiesKnownBySelector (node) {
node.path = '/';
node.root = true;
node.uid = PuterPath.NULL_UUID;
}
constructor () {
super();
this.entry = this.constructor.entry;
}
describe () {
return '[root]';
}
}
class NodeRawEntrySelector extends NodeSelector {
constructor (entry, details_about_fetch = {}) {
super();
// The `details_about_fetch` object lets us simulate non-entry state
// that occurs after a node has been fetched
this.details_about_fetch = details_about_fetch;
// Fix entries from get_descendants
if ( !entry.uuid && entry.uid ) {
entry.uuid = entry.uid;
if ( entry._id ) {
entry.id = entry._id;
delete entry._id;
}
}
this.entry = entry;
}
setPropertiesKnownBySelector (node) {
if ( this.details_about_fetch.found_thumbnail ) {
node.found_thumbnail = true;
}
node.found = true;
node.entry = this.entry;
node.uid = this.entry.uid ?? this.entry.uuid;
node.name = this.entry.name;
if ( this.entry.path ) node.path = this.entry.path;
if ( this.entry.subdomains ) {
node.subdomains = this.entry.subdomains;
}
}
describe () {
return '[raw entry]';
}
}
/**
* Try to infer following attributes for a selector:
* - path
* - uid
*
* @param {NodePathSelector | NodeUIDSelector | NodeChildSelector | RootNodeSelector | NodeRawEntrySelector} selector
*/
function try_infer_attributes (selector) {
if ( selector instanceof NodePathSelector ) {
selector.path = selector.value;
} else if ( selector instanceof NodeUIDSelector ) {
selector.uid = selector.value;
} else if ( selector instanceof NodeChildSelector ) {
try_infer_attributes(selector.parent);
if ( selector.parent.path ) {
selector.path = _path.join(selector.parent.path, selector.name);
}
} else if ( selector instanceof RootNodeSelector ) {
selector.path = '/';
} else {
// give up
}
}
const relativeSelector = (parent, path) => {
if ( path === '.' ) return parent;
if ( path.startsWith('..') ) {
throw new Error('currently unsupported');
}
let selector = parent;
const parts = path.split('/').filter(Boolean);
for ( const part of parts ) {
selector = new NodeChildSelector(selector, part);
}
return selector;
};
module.exports = {
NodeSelector,
NodePathSelector,
NodeUIDSelector,
NodeInternalIDSelector,
NodeChildSelector,
RootNodeSelector,
NodeRawEntrySelector,
relativeSelector,
try_infer_attributes,
};
================================================
FILE: src/backend/src/filesystem/node/states.js
================================================
/*
* 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 .
*/
class NodeFoundState {
}
class NodeDoesNotExistState {
}
class NodeInitialState {
}
================================================
FILE: src/backend/src/filesystem/storage/UploadProgressTracker.js
================================================
/*
* 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 .
*/
class UploadProgressTracker {
constructor () {
this.progress_ = 0;
this.total_ = 0;
this.done_ = false;
this.listeners_ = [];
}
set_total (v) {
this.total_ = v;
}
set (value) {
if ( value < this.progress_ ) {
// TODO: provide a logger for a warning
return;
}
const delta = value - this.progress_;
this.add(delta);
}
add (amount) {
if ( this.done_ ) {
return; // TODO: warn
}
this.progress_ += amount;
for ( const lis of this.listeners_ ) {
lis(amount);
}
this.check_if_done_();
}
sub (callback) {
if ( this.done_ ) {
return;
}
const listeners = this.listeners_;
listeners.push(callback);
const det = {
detach: () => {
const idx = listeners.indexOf(callback);
if ( idx !== -1 ) {
listeners.splice(idx, 1);
}
},
};
return det;
}
check_if_done_ () {
if ( this.progress_ === this.total_ ) {
this.done_ = true;
// clear listeners so they get GC'd
this.listeners_ = [];
}
}
}
module.exports = {
UploadProgressTracker,
};
================================================
FILE: src/backend/src/filesystem/strategies/README.md
================================================
## Puter Filesystem Strategies
Each subdirectory is named in the format `_`,
where `` specifies broadly what that strategies contained within
the directory are concerned with (storage, fsentry, etc), and ``
is a letter from A-Z indicating the layer/level of concern.
The class **A** indicates that this is the highest level of swappable
behaviour, which generally means there will be two strategies:
- one which supports legacy behaviour that is coupled with multiple concerns
- one which adapts more cohesive strategies to an interface which
supports the case above.
================================================
FILE: src/backend/src/filesystem/strategies/storage_a/LocalDiskStorageStrategy.js
================================================
/*
* 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 .
*/
const { BaseOperation } = require('../../../services/OperationTraceService');
/**
* Handles file upload operations to local disk storage.
* Extends BaseOperation to provide upload functionality with progress tracking.
*/
class LocalDiskUploadStrategy extends BaseOperation {
/**
* Creates a new LocalDiskUploadStrategy instance.
* @param {Object} parent - The parent storage strategy instance
*/
constructor (parent) {
super();
this.parent = parent;
this.uid = null;
}
/**
* Executes the upload operation by storing file data to local disk.
* Handles both buffer and stream-based uploads with progress tracking.
* @returns {Promise} Resolves when the upload is complete
*/
async _run () {
const { uid, file, storage_api } = this.values;
const { progress_tracker } = storage_api;
if ( file.buffer ) {
await this.parent.svc_localDiskStorage.store_buffer({
key: uid,
buffer: file.buffer,
});
progress_tracker.set_total(file.buffer.length);
progress_tracker.set(file.buffer.length);
} else {
await this.parent.svc_localDiskStorage.store_stream({
key: uid,
stream: file.stream,
size: file.size,
on_progress: evt => {
progress_tracker.set_total(file.size);
progress_tracker.set(evt.uploaded);
},
});
}
}
/**
* Hook called after the operation is inserted into the trace.
*/
post_insert () {
}
}
/**
* Handles file copy operations within local disk storage.
* Extends BaseOperation to provide copy functionality with progress tracking.
*/
class LocalDiskCopyStrategy extends BaseOperation {
/**
* Creates a new LocalDiskCopyStrategy instance.
* @param {Object} parent - The parent storage strategy instance
*/
constructor (parent) {
super();
this.parent = parent;
}
/**
* Executes the copy operation by duplicating a file from source to destination.
* Updates progress tracker to indicate completion.
* @returns {Promise} Resolves when the copy is complete
*/
async _run () {
const { src_node, dst_storage, storage_api } = this.values;
const { progress_tracker } = storage_api;
await this.parent.svc_localDiskStorage.copy({
src_key: await src_node.get('uid'),
dst_key: dst_storage.key,
});
// for now we just copy the file, we don't care about the progress
progress_tracker.set_total(1);
progress_tracker.set(1);
}
/**
* Hook called after the operation is inserted into the trace.
*/
post_insert () {
}
}
/**
* Handles file deletion operations from local disk storage.
* Extends BaseOperation to provide delete functionality.
*/
class LocalDiskDeleteStrategy extends BaseOperation {
/**
* Creates a new LocalDiskDeleteStrategy instance.
* @param {Object} parent - The parent storage strategy instance
*/
constructor (parent) {
super();
this.parent = parent;
}
/**
* Executes the delete operation by removing a file from local disk storage.
* @returns {Promise} Resolves when the deletion is complete
*/
async _run () {
const { node } = this.values;
await this.parent.svc_localDiskStorage.delete({
key: await node.get('uid'),
});
}
}
/**
* Main strategy class for managing local disk storage operations.
* Provides factory methods for creating upload, copy, and delete operations.
*/
class LocalDiskStorageStrategy {
/**
* Creates a new LocalDiskStorageStrategy instance.
* @param {Object} config - Configuration object
* @param {Object} config.services - Services container for dependency injection
*/
constructor ({ services }) {
this.svc_localDiskStorage = services.get('local-disk-storage');
}
/**
* Creates a new upload operation instance.
* @returns {LocalDiskUploadStrategy} A new upload strategy instance
*/
create_upload () {
return new LocalDiskUploadStrategy(this);
}
/**
* Creates a new copy operation instance.
* @returns {LocalDiskCopyStrategy} A new copy strategy instance
*/
create_copy () {
return new LocalDiskCopyStrategy(this);
}
/**
* Creates a new delete operation instance.
* @returns {LocalDiskDeleteStrategy} A new delete strategy instance
*/
create_delete () {
return new LocalDiskDeleteStrategy(this);
}
/**
* Creates a readable stream for accessing file data from local disk storage.
* @param {string} uid - The unique identifier of the file to read
* @param {Object} [options={}] - Optional parameters for stream creation
* @returns {Promise} A readable stream for the file data
*/
async create_read_stream (uid, options = {}) {
return await this.svc_localDiskStorage.create_read_stream(uid, options);
}
}
module.exports = {
LocalDiskStorageStrategy,
};
================================================
FILE: src/backend/src/filesystem/strategies/storage_a/README.md
================================================
## Class A Storage Strategies
This is the broadest definition of storage strategies.
This is to allow swapping between the behaviour of the original
Puter storage logic, and Class B storage strategies.
- they know the UID of the file
- they can perform post-operations after the fsentry is inserted
- they can access the Puter database
================================================
FILE: src/backend/src/filesystem/validation.bench.js
================================================
/*
* 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 .
*/
import { bench, describe } from 'vitest';
const { is_valid_path, is_valid_node_name } = require('./validation');
// Test data
const shortPath = '/home/user/file.txt';
const mediumPath = '/home/user/documents/projects/puter/src/backend/file.js';
const longPath = '/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.txt';
const deeplyNestedPath = `${Array(50).fill('directory').join('/') }/file.txt`;
const simpleFilename = 'document.pdf';
const filenameWithSpaces = 'my document file.pdf';
const filenameWithNumbers = 'report_2024_final_v2.xlsx';
const maxLengthFilename = 'a'.repeat(255);
// Invalid paths for testing rejection speed
const pathWithNull = '/home/user/\x00file.txt';
const pathWithRTL = '/home/user/\u202Efile.txt';
const pathWithLTR = '/home/user/\u200Efile.txt';
describe('is_valid_path - Valid paths', () => {
bench('short path (/home/user/file.txt)', () => {
is_valid_path(shortPath);
});
bench('medium path (~50 chars)', () => {
is_valid_path(mediumPath);
});
bench('long path (26 components)', () => {
is_valid_path(longPath);
});
bench('deeply nested path (50 components)', () => {
is_valid_path(`/${ deeplyNestedPath}`);
});
bench('relative path starting with dot', () => {
is_valid_path('./relative/path/to/file.txt');
});
});
describe('is_valid_path - With options', () => {
bench('with no_relative_components option', () => {
is_valid_path(mediumPath, { no_relative_components: true });
});
bench('with allow_path_fragment option', () => {
is_valid_path('partial/path/fragment', { allow_path_fragment: true });
});
bench('with both options', () => {
is_valid_path(shortPath, { no_relative_components: true, allow_path_fragment: true });
});
});
describe('is_valid_path - Invalid paths (rejection speed)', () => {
bench('path with null character', () => {
is_valid_path(pathWithNull);
});
bench('path with RTL override', () => {
is_valid_path(pathWithRTL);
});
bench('path with LTR mark', () => {
is_valid_path(pathWithLTR);
});
bench('empty string', () => {
is_valid_path('');
});
bench('non-string input (number)', () => {
is_valid_path(12345);
});
bench('path not starting with / or .', () => {
is_valid_path('invalid/path/start');
});
});
describe('is_valid_node_name - Valid names', () => {
bench('simple filename', () => {
is_valid_node_name(simpleFilename);
});
bench('filename with spaces', () => {
is_valid_node_name(filenameWithSpaces);
});
bench('filename with numbers and underscores', () => {
is_valid_node_name(filenameWithNumbers);
});
bench('filename at max length (255 chars)', () => {
is_valid_node_name(maxLengthFilename);
});
bench('filename with multiple extensions', () => {
is_valid_node_name('archive.tar.gz');
});
});
describe('is_valid_node_name - Invalid names (rejection speed)', () => {
bench('name with forward slash', () => {
is_valid_node_name('invalid/name');
});
bench('name with null character', () => {
is_valid_node_name('invalid\x00name');
});
bench('single dot (.)', () => {
is_valid_node_name('.');
});
bench('double dot (..)', () => {
is_valid_node_name('..');
});
bench('only dots (...)', () => {
is_valid_node_name('...');
});
bench('name exceeding max length', () => {
is_valid_node_name('a'.repeat(300));
});
bench('non-string input', () => {
is_valid_node_name(null);
});
});
describe('is_valid_path - Batch validation simulation', () => {
const paths = [
'/home/user/file1.txt',
'/home/user/file2.txt',
'/home/user/documents/report.pdf',
'/var/log/system.log',
'/etc/config.json',
];
bench('validate 5 paths sequentially', () => {
for ( const path of paths ) {
is_valid_path(path);
}
});
bench('validate 100 paths', () => {
for ( let i = 0; i < 100; i++ ) {
is_valid_path(paths[i % paths.length]);
}
});
});
================================================
FILE: src/backend/src/filesystem/validation.js
================================================
/*
* 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 .
*/
/* ~~~ Filesystem validation ~~~
This module contains functions that validate filesystem operations.
*/
/* eslint-disable no-control-regex */
const config = require('../config');
const path_excludes = () => /[\x00-\x1F]/g;
const node_excludes = () => /[/\x00-\x1F]/g;
// this characters are not allowed in path names because
// they might be used to trick the user into thinking
// a filename is different from what it actually is.
const safety_excludes = [
/[\u202A-\u202E]/, // RTL and LTR override
/[\u200E-\u200F]/, // RTL and LTR mark
/[\u2066-\u2069]/, // RTL and LTR isolate
/[\u2028-\u2029]/, // line and paragraph separator
/[\uFF01-\uFF5E]/, // fullwidth ASCII
/[\u2060]/, // word joiner
/[\uFEFF]/, // zero width no-break space
/[\uFFFE-\uFFFF]/, // non-characters
];
const is_valid_node_name = function is_valid_node_name (name) {
if ( typeof name !== 'string' ) return false;
if ( node_excludes().test(name) ) return false;
for ( const exclude of safety_excludes ) {
if ( exclude.test(name) ) return false;
}
if ( name.length > config.max_fsentry_name_length ) return false;
// Names are allowed to contain dots, but cannot
// contain only dots. (this covers '.' and '..')
const name_without_dots = name.replace(/\./g, '');
if ( name_without_dots.length < 1 ) return false;
return true;
};
const is_valid_path = function is_valid_path (path, {
no_relative_components,
allow_path_fragment,
} = {}) {
if ( typeof path !== 'string' ) return false;
if ( path.length < 1 ) false;
if ( path_excludes().test(path) ) return false;
for ( const exclude of safety_excludes ) {
if ( exclude.test(path) ) return false;
}
if ( ! allow_path_fragment ) {
if ( path[0] !== '/' && path[0] !== '.' ) {
return false;
}
}
if ( no_relative_components ) {
const components = path.split('/');
for ( const component of components ) {
if ( component === '' ) continue;
const name_without_dots = component.replace(/\./g, '');
if ( name_without_dots.length < 1 ) return false;
}
}
return true;
};
module.exports = {
is_valid_node_name,
is_valid_path,
};
================================================
FILE: src/backend/src/helpers.js
================================================
/*
* 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 .
*/
import { sha256 } from 'js-sha256';
import { LRUCache } from 'lru-cache';
import micromatch from 'micromatch';
import { contentType as _contentType } from 'mime-types';
import { resolve as _resolve, extname } from 'path';
import { v4 } from 'uuid';
import APIError from './api/APIError.js';
import { setRedisCacheValue } from './clients/redis/cacheUpdate.js';
import { redisClient } from './clients/redis/redisSingleton.js';
import config from './config.js';
import { APP_ICONS_SUBDOMAIN } from './consts/app-icons.js';
import { NodeUIDSelector } from './filesystem/node/selectors.js';
import { AppRedisCacheSpace } from './modules/apps/AppRedisCacheSpace.js';
import { DB_READ, DB_WRITE } from './services/database/consts.js';
import { UserRedisCacheSpace } from './services/UserRedisCacheSpace.js';
import { Context } from './util/context.js';
import { ManagedError } from './util/errorutil.js';
import { generate_identifier } from './util/identifier.js';
import { kv } from './util/kvSingleton.js';
import { spanify } from './util/otelutil.js';
export * from './validation.js';
// 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 servicesContainer = globalThis[SERVICES_KEY];
export async function tmp_provide_services (ss) {
servicesContainer.services = ss;
await servicesContainer.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 RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
const safe_json_parse = (value, fallback) => {
if ( value === null || value === undefined ) return fallback;
try {
return JSON.parse(value);
} catch ( error ) {
return fallback;
}
};
const redisGetJsonMany = async (keys) => {
if ( !Array.isArray(keys) || keys.length === 0 ) {
return new Map();
}
const uniqueKeys = [...new Set(keys)];
let valuesByIndex = null;
// MGET over Redis Cluster can fail for cross-slot keys; use pipelined GETs there.
if ( typeof redisClient.nodes === 'function' ) {
const pipeline = redisClient.pipeline();
for ( const key of uniqueKeys ) {
pipeline.get(key);
}
const results = await pipeline.exec();
if ( Array.isArray(results) ) {
valuesByIndex = results.map((item) => {
if ( !Array.isArray(item) || item.length < 2 ) return null;
const [error, value] = item;
return error ? null : value;
});
}
} else if ( typeof redisClient.mget === 'function' ) {
valuesByIndex = await redisClient.mget(...uniqueKeys);
}
if ( ! Array.isArray(valuesByIndex) ) {
valuesByIndex = await Promise.all(uniqueKeys.map(key => redisClient.get(key)));
}
const valuesByKey = new Map();
for ( let i = 0; i < uniqueKeys.length; i++ ) {
valuesByKey.set(uniqueKeys[i], safe_json_parse(valuesByIndex[i], null));
}
return valuesByKey;
};
const normalizeAppUid = (app_uid) => {
if ( ! app_uid ) return null;
const uid_string = String(app_uid);
return uid_string.startsWith('app-') ? uid_string : `app-${uid_string}`;
};
const isRawBase64ImageString = value => {
if ( typeof value !== 'string' ) return false;
const trimmed = value.trim();
if ( !trimmed || trimmed.length < 16 ) return false;
if ( ! RAW_BASE64_REGEX.test(trimmed) ) return false;
if ( trimmed.length % 4 !== 0 ) return false;
try {
const decoded = Buffer.from(trimmed, 'base64');
if ( decoded.length === 0 ) return false;
const normalizedInput = trimmed.replace(/=+$/, '');
const reencoded = decoded.toString('base64').replace(/=+$/, '');
return normalizedInput === reencoded;
} catch {
return false;
}
};
const isBase64AppIcon = (app) => {
if ( !app || typeof app !== 'object' ) return false;
const flag = app.icon_is_base64;
if ( typeof flag === 'boolean' ) return flag;
if ( typeof flag === 'number' ) return flag !== 0;
if ( typeof flag === 'string' ) {
const lowered = flag.toLowerCase();
if ( lowered === '1' || lowered === 'true' ) return true;
if ( lowered === '0' || lowered === 'false' ) return false;
}
const icon = app.icon;
if ( typeof icon !== 'string' ) return false;
const trimmed = icon.trim();
if ( trimmed.startsWith('data:image/') ) return true;
return isRawBase64ImageString(trimmed);
};
export async function is_empty (dir_uuid) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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}
*/
export async function is_temp_users_disabled () {
const svc_feature_flag = await servicesContainer.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}
*/
export async function is_user_signup_disabled () {
const svc_feature_flag = await servicesContainer.services.get('feature-flag');
return await svc_feature_flag.check('user-signup-disabled');
}
export 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
*/
export 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
*/
export async function id2uuid (id) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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
*/
export async function df (user_id) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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 {object} options - `options`
* @returns {Promise}
*/
export async function get_user (options) {
return await servicesContainer.services.get('get-user').get_user(options);
}
/**
* Invalidate the cached entries for a user object
*
* @param {User} userID - the user entry to invalidate
*/
export const invalidate_cached_user = async (user) => {
await UserRedisCacheSpace.invalidateUser(user);
};
/**
* Invalidate the cached entries for the user specified by an id
* @param {number} id - the id of the user to invalidate
*/
export const invalidate_cached_user_by_id = async (id) => {
await UserRedisCacheSpace.invalidateById(id);
};
export async function refresh_associations_cache () {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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 setRedisCacheValue(
AppRedisCacheSpace.associationAppsKey(k),
JSON.stringify(lists[k]),
{ eventData: lists[k] },
);
}
}
/**
* Get App by a variety of IDs
*
* @param {{[key:'name'|'id'|'uid']?:string}} options - `options`
* @returns {Promise}
*/
export async function get_app (options) {
const cacheApp = async (app) => {
if ( ! app ) return;
AppRedisCacheSpace.setCachedApp(app, {
ttlSeconds: 30,
});
};
const isDecoratedAppCacheEntry = (app) => (
!!app &&
typeof app === 'object' &&
Object.prototype.hasOwnProperty.call(app, 'icon_is_base64')
);
// This condition should be updated if the code below is re-ordered.
if ( options.follow_old_names && !options.uid && options.name ) {
const svc_oldAppName = servicesContainer.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 = AppRedisCacheSpace.key({
lookup: 'uid',
value: options.uid,
});
} else if ( options.name ) {
queryKey = `name:${options.name}`;
cacheKey = AppRedisCacheSpace.key({
lookup: 'name',
value: options.name,
});
} else if ( options.id ) {
queryKey = `id:${options.id}`;
cacheKey = AppRedisCacheSpace.key({
lookup: 'id',
value: options.id,
});
} else {
// No valid lookup parameter
return null;
}
// Check cache first
let app = safe_json_parse(await redisClient.get(cacheKey), null);
if ( isDecoratedAppCacheEntry(app) ) {
AppRedisCacheSpace.invalidateCachedApp(app);
app = 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 separatorIndex = queryKey.indexOf(':');
const pendingLookup = queryKey.slice(0, separatorIndex);
const pendingValue = queryKey.slice(separatorIndex + 1);
const pendingKey = AppRedisCacheSpace.pendingKey({
lookup: pendingLookup,
value: pendingValue,
});
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 = servicesContainer.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;
}
const get_app_icon_url = (app, size) => {
const iconIsBase64 = isBase64AppIcon(app);
const svc_appIcon = servicesContainer.services.get('app-icon');
const app_uid = app.uid ?? app.uuid;
// For base64 icons, or if `no_subdomain` was set in config, use the
// `/app-icon` endpoint on Puter's backend as the URL for this icon.
if ( iconIsBase64 || svc_appIcon.config.no_subdomain ) {
if ( ! app_uid ) return null;
const normalized_uid = normalizeAppUid(app_uid);
const iconSize = Number.isFinite(Number(size)) ? Number(size) : DEFAULT_APP_ICON_SIZE;
try {
const iconPath = svc_appIcon?.getAppIconPath?.({
appUid: normalized_uid,
size: iconSize,
});
if ( iconPath ) return iconPath;
} catch {
// Fall back to direct URL generation below.
}
const apiBaseUrl = String(config.api_base_url || '').replace(/\/+$/, '');
if ( ! apiBaseUrl ) return null;
return `${apiBaseUrl}/app-icon/${normalized_uid}/${iconSize}`;
}
// Otherwise, the icon has a URL under `puter-app-icons.puter.site`
// (or the `puter-app-icons` subdomain of this Puter instance's static hosting domain)
if ( ! app_uid ) return null;
const normalized_uid = normalizeAppUid(app_uid);
const iconSize = 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}-${iconSize}.png`;
};
/**
* 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]
* @returns {Promise>}
*/
export const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
if ( ! Array.isArray(specifiers) ) {
specifiers = [specifiers];
}
const decorateApp = (app) => {
if ( ! app ) return app;
const icon_url = get_app_icon_url(app.uid ?? app.uuid);
if ( ! icon_url ) return { ...app };
return { ...app, icon: icon_url };
};
const normalizeAppForCache = (app) => {
if ( ! app ) return app;
const normalized = { ...app };
delete normalized.icon_is_base64;
return normalized;
};
const isDecoratedAppCacheEntry = (app) => (
!!app &&
typeof app === 'object' &&
Object.prototype.hasOwnProperty.call(app, 'icon_is_base64')
);
const cacheApp = async (app) => {
if ( ! app ) return;
AppRedisCacheSpace.setCachedApp(app, {
ttlSeconds: 60,
});
};
const normalized = specifiers.map(spec => spec ? { ...spec } : {});
if ( options.follow_old_names ) {
const svc_oldAppName = servicesContainer.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 separatorIndex = queryKey.indexOf(':');
const lookup = queryKey.slice(0, separatorIndex);
value = queryKey.slice(separatorIndex + 1);
const pendingKey = AppRedisCacheSpace.pendingKey({
lookup,
value,
});
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);
}
};
const cacheLookupPlan = normalized.map((spec) => {
if ( spec.uid ) {
return {
lookup: 'uid',
value: spec.uid,
cacheKey: AppRedisCacheSpace.key({
lookup: 'uid',
value: spec.uid,
}),
};
}
if ( spec.name ) {
return {
lookup: 'name',
value: spec.name,
cacheKey: AppRedisCacheSpace.key({
lookup: 'name',
value: spec.name,
}),
};
}
if ( spec.id ) {
return {
lookup: 'id',
value: spec.id,
cacheKey: AppRedisCacheSpace.key({
lookup: 'id',
value: spec.id,
}),
};
}
return null;
});
const cachedAppsByKey = await redisGetJsonMany(
cacheLookupPlan.filter(Boolean).map(item => item.cacheKey),
);
for ( const plannedLookup of cacheLookupPlan ) {
if ( ! plannedLookup ) continue;
let cached = cachedAppsByKey.get(plannedLookup.cacheKey);
if ( isDecoratedAppCacheEntry(cached) ) {
AppRedisCacheSpace.invalidateCachedApp(cached);
cached = null;
}
if ( cached ) {
addApp(decorateApp(cached));
} else {
queueMissing(plannedLookup.lookup, plannedLookup.value);
}
}
const pendingResultsPromise = pendingLookups.size
? Promise.all(Array.from(pendingLookups.values()))
: Promise.resolve([]);
if ( queryUids.size || queryNames.size || queryIds.size ) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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 {
rows = await db.read(
`SELECT *, CASE WHEN icon LIKE 'data:%' THEN 1 ELSE 0 END AS icon_is_base64 FROM \`apps\` WHERE ${clauses.join(' OR ')}`,
params,
);
for ( const app of rows ) {
const appForCache = normalizeAppForCache(app);
cacheApp(appForCache);
const decorated_app = decorateApp(appForCache);
addApp(decorated_app);
const uidKey = `uid:${appForCache.uid}`;
const nameKey = `name:${appForCache.name}`;
const idKey = `id:${appForCache.id}`;
if ( pendingToResolve.has(uidKey) ) {
pendingToResolve.get(uidKey).resolveQuery(appForCache);
resolvedKeys.add(uidKey);
}
if ( pendingToResolve.has(nameKey) ) {
pendingToResolve.get(nameKey).resolveQuery(appForCache);
resolvedKeys.add(nameKey);
}
if ( pendingToResolve.has(idKey) ) {
pendingToResolve.get(idKey).resolveQuery(appForCache);
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() ) {
kv.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);
}
if ( ! app ) return null;
const result = { ...app };
delete result.icon_is_base64;
return result;
});
});
/**
* Checks to see if an app exists
*
* @param {string} options - `options`
* @returns {Promise}
*/
export async function app_exists (options) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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}
*/
export async function change_username (user_id, new_username) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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 servicesContainer.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
*/
export async function uuid2fsentry (uuid, return_thumbnail) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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
*/
export async function id2fsentry (id, return_thumbnail) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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
*/
export 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 = servicesContainer.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
*/
export 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]}`;
};
export const get_descendants = spanify('get_descendants', async (...args) => {
return await getDescendantsHelper(...args);
});
/**
*
* @param {integer} entry_id
* @returns
*/
export 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 = servicesContainer.services.get('database').get(DB_READ, 'filesystem');
const log = servicesContainer.services.get('log-service').create('helpers.id2path');
log.traceOn();
const errors = servicesContainer.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 = servicesContainer.services.get('log-service').create('get_descendants');
log.called();
// decrement depth if it's set
depth !== undefined && depth--;
// turn path into absolute form
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 = servicesContainer.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 = _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();
};
export 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
*/
export async function resolve_glob (glob, user) {
//turn glob into abs path
glob = _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;
}
export 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} 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,
});
}
export 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 = servicesContainer.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;
}
export async function sign_file (fsentry, action) {
// 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 = _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,
};
}
export async function gen_public_token (file_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 = v4();
const contentType = _contentType(fsentry.name);
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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,
};
}
export async function deleteUser (user_id) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.services.get('database').get(DB_READ, 'filesystem');
const svc_fs = servicesContainer.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]);
}
export function subdomain (req) {
if ( config.experimental_no_subdomain ) return 'api';
return req.hostname.slice(0, -1 * (config.domain.length + 1));
}
export async function jwt_auth (req, authService) {
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 {
if ( ! authService ) {
throw new Error('jwt_auth requires authService');
}
const actor = await authService.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
*/
export async function ancestors (fsentry_id) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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;
}
export 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;
}
export async function username_exists (username) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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;
}
}
export async function generate_random_username () {
let username;
do {
username = generate_identifier();
} while ( await username_exists(username) );
return username;
}
export async function app_name_exists (name) {
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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 = servicesContainer.services.get('old-app-name');
const name_info = await svc_oldAppName.check_app_name(name);
if ( name_info ) return true;
}
export 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),
});
}
export 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 });
}
export 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.
*/
export 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 = _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 = 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(
AppRedisCacheSpace.associationAppsKey(file_extension.slice(1)),
), []);
/** @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
);
export 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;
}
export async function suggestedAppForFsEntry (fsentry, options) {
const [result] = await suggestedAppsForFsEntries([fsentry], options);
return result;
}
export async function get_taskbar_items (user, {
icon_size: iconSizeFromSnake,
iconSize: iconSizeFromCamel,
no_icons,
} = {}) {
const iconSize = iconSizeFromCamel ?? iconSizeFromSnake;
/** @type BaseDatabaseAccessService */
const db = servicesContainer.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);
// 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 {
item.icon = get_app_icon_url(item, iconSize);
}
// add to final object
taskbar_items.push(item);
}
return taskbar_items;
}
export 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;
// 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' };
}
}
export 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.
*/
export 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);
}
================================================
FILE: src/backend/src/index.js
================================================
/*
* 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 .
*/
'use strict';
const { Kernel } = require('./Kernel');
const CoreModule = require('./CoreModule');
const { CaptchaModule } = require('./modules/captcha/CaptchaModule'); // Add CaptchaModule
const testlaunch = () => {
const k = new Kernel();
k.add_module(new CoreModule());
k.add_module(new CaptchaModule()); // Register the CaptchaModule
k.boot();
};
module.exports = { testlaunch };
================================================
FILE: src/backend/src/kernel/modutil.js
================================================
const fs = require('fs').promises;
const path = require('path');
async function prependToJSFiles (directory, snippet) {
const jsExtensions = new Set(['.js', '.cjs', '.mjs', '.ts']);
async function processDirectory (dir) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const promises = [];
for ( const entry of entries ) {
const fullPath = path.join(dir, entry.name);
if ( entry.isDirectory() ) {
// Skip common directories that shouldn't be modified
if ( ! shouldSkipDirectory(entry.name) ) {
promises.push(processDirectory(fullPath));
}
} else if ( entry.isFile() && jsExtensions.has(path.extname(entry.name)) ) {
promises.push(prependToFile(fullPath, snippet));
}
}
await Promise.all(promises);
} catch ( error ) {
throw new Error(`error processing directory ${dir}`, {
cause: error,
});
}
}
function shouldSkipDirectory (dirName) {
const skipDirs = new Set([
'node_modules',
'gui',
]);
if ( skipDirs.has(dirName) ) return true;
if ( dirName.startsWith('.') ) return true;
return false;
}
async function prependToFile (filePath, snippet) {
try {
const content = await fs.readFile(filePath, 'utf8');
if ( content.startsWith('//!no-prepend') ) return;
const newContent = snippet + content;
await fs.writeFile(filePath, newContent, 'utf8');
} catch ( error ) {
throw new Error(`error processing file ${filePath}`, {
cause: error,
});
}
}
await processDirectory(directory);
}
module.exports = {
prependToJSFiles,
};
================================================
FILE: src/backend/src/loadTestConfig.js
================================================
const config = require('./config.js');
module.exports = {
config,
};
================================================
FILE: src/backend/src/middleware/abuse.js
================================================
/*
* 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 .
*/
const APIError = require('../api/APIError');
const config = require('../config');
const { Context } = require('../util/context');
const abuse = options => (req, res, next) => {
if ( config.disable_abuse_checks ) {
next(); return;
}
const requester = Context.get('requester');
if ( options.no_bots ) {
if ( requester.is_bot ) {
if ( options.shadow_ban_responder ) {
return options.shadow_ban_responder(req, res);
}
throw APIError.create('forbidden');
}
}
if ( options.puter_origin ) {
if ( ! requester.is_puter_origin() ) {
throw APIError.create('forbidden');
}
}
next();
};
module.exports = abuse;
================================================
FILE: src/backend/src/middleware/anticsrf.js
================================================
/*
* 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 .
*/
const APIError = require('../api/APIError');
/**
* Creates an anti-CSRF middleware that validates CSRF tokens in incoming requests.
* This middleware protects against Cross-Site Request Forgery attacks by verifying
* that requests contain a valid anti-CSRF token in the request body.
*
* @param {Object} options - Configuration options for the middleware
* @returns {Function} Express middleware function that validates CSRF tokens
*
* @example
* // Apply anti-CSRF protection to a route
* app.post('/api/secure-endpoint', anticsrf(), (req, res) => {
* // Route handler code
* });
*/
const anticsrf = options => async (req, res, next) => {
const svc_antiCSRF = req.services.get('anti-csrf');
if ( ! req.body.anti_csrf ) {
const err = APIError.create('anti-csrf-incorrect');
err.write(res);
return;
}
const has = svc_antiCSRF.consume_token(req.user.uuid, req.body.anti_csrf);
if ( ! has ) {
const err = APIError.create('anti-csrf-incorrect');
err.write(res);
return;
}
next();
};
module.exports = anticsrf;
================================================
FILE: src/backend/src/middleware/auth.js
================================================
/*
* 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 .
*/
'use strict';
const APIError = require('../api/APIError');
const { UserActorType } = require('../services/auth/Actor');
const auth2 = require('./auth2');
const auth = async (req, res, next) => {
let auth2_ok = false;
try {
// Delegate to new middleware
await auth2(req, res, () => {
auth2_ok = true;
});
if ( ! auth2_ok ) return;
// Everything using the old reference to the auth middleware
// should only allow session tokens
if ( ! (req.actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
next();
}
// auth failed
catch (e) {
return res.status(401).send(e);
}
};
module.exports = auth;
================================================
FILE: src/backend/src/middleware/auth2.js
================================================
/*
* 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 .
*/
const configurable_auth = require('./configurable_auth');
const auth2 = configurable_auth({ optional: false });
module.exports = auth2;
================================================
FILE: src/backend/src/middleware/configurable_auth.js
================================================
/*
* 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 .
*/
const APIError = require('../api/APIError');
const config = require('../config');
const { LegacyTokenError } = require('../services/auth/AuthService');
const { AccessTokenActorType } = require('../services/auth/Actor');
const { Context } = require('../util/context');
// The "/whoami" endpoint is a special case where we want to allow
// a legacy token to be used for authentication. The "/whoami"
// endpoint will then return a new token for further requests.
//
const is_whoami = (req) => {
if ( ! config.legacy_token_migrate ) return;
if ( req.path !== '/whoami' ) return;
// const subdomain = req.subdomains[res.subdomains.length - 1];
// if ( subdomain !== 'api' ) return;
return true;
};
// TODO: Allow auth middleware to be used without requiring
// authentication. This will allow us to use the auth middleware
// in endpoints that do not require authentication, but can
// provide additional functionality if the user is authenticated.
const configurable_auth = options => async (req, res, next) => {
if ( options?.no_options_auth && req.method === 'OPTIONS' ) {
return next();
}
const optional = options?.optional;
const allow_cached_user = options?.allow_cached_user;
// Request might already have been authed (PreAuthService)
if ( req.actor ) return next();
// === Getting the Token ===
// This step came from jwt_auth in src/helpers.js
// However, since request-response handling is a concern of the
// auth middleware, it makes more sense to put it here.
let token;
let tokenSource;
// Auth token in body
if ( req.body && req.body.auth_token )
{
token = req.body.auth_token;
tokenSource = 'body';
}
// HTTML Auth header
else if ( req.header && req.header('Authorization') && !req.header('Authorization').startsWith('Basic ') && req.header('Authorization') !== 'Bearer' ) { // Bearer with no space is something office does
token = req.header('Authorization');
token = token.replace('Bearer ', '').trim();
tokenSource = 'header';
if ( token === 'undefined' ) {
APIError.create('unexpected_undefined', null, {
msg: 'The Authorization token cannot be the string "undefined"',
});
}
}
// Cookie
else if ( req.cookies && req.cookies[config.cookie_name] )
{
token = req.cookies[config.cookie_name];
tokenSource = 'cookie';
}
// Auth token in URL
else if ( req.query && req.query.auth_token )
{
token = req.query.auth_token;
tokenSource = 'query';
}
// Socket
else if ( req.handshake && req.handshake.query && req.handshake.query.auth_token )
{
token = req.handshake.query.auth_token;
tokenSource = 'socket';
}
if ( !token || token.startsWith('Basic ') ) {
if ( optional ) {
next();
return;
}
APIError.create('token_missing').write(res);
return;
} else if ( typeof token !== 'string' ) {
APIError.create('token_auth_failed').write(res);
return;
} else {
token = token.replace('Bearer ', '');
}
// === Delegate to AuthService ===
// AuthService will attempt to authenticate the token and return
// an Actor object, which is a high-level representation of the
// entity that is making the request; it could be a user, an app
// acting on behalf of a user, or an app acting on behalf of itself.
const context = Context.get();
const services = context.get('services');
const svc_auth = services.get('auth');
let actor;
try {
actor = await svc_auth.authenticate_from_token(token);
} catch ( e ) {
if ( e instanceof APIError ) {
e.write(res);
return;
}
if ( e instanceof LegacyTokenError && is_whoami(req) ) {
const new_info = await svc_auth.check_session(token, {
req,
from_upgrade: true,
});
context.set('actor', new_info.actor);
context.set('user', new_info.user);
req.new_token = new_info.token;
req.token = new_info.token;
req.user = new_info.user;
req.actor = new_info.actor;
if ( req.user?.suspended ) {
throw APIError.create('forbidden');
}
// Use session token in cookie so cookie-based requests have hasHttpOnlyCookie; client gets GUI token in response
res.cookie(config.cookie_name, new_info.session_token ?? new_info.token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
next();
return;
}
const re = APIError.create('token_auth_failed');
re.write(res);
return;
}
// === Populate Context ===
context.set('actor', actor);
if ( actor.type.user ) {
if ( allow_cached_user === false ) {
const svc_getUser = services.get('get-user');
actor.type.user = await svc_getUser.get_user({ id: actor.type.user.id, force: true });
}
if ( actor.type.user?.suspended ) {
throw APIError.create('forbidden');
}
context.set('user', actor.type.user);
}
if ( actor.type instanceof AccessTokenActorType ) {
// AccessTokenActorType has no .user; the effective user is the authorizer's user
const authorizerUser = actor.type.authorizer?.type?.user;
if ( authorizerUser?.suspended ) {
throw APIError.create('forbidden');
}
}
// === Populate Request ===
req.actor = actor;
req.user = actor.type.user ?? (actor.type instanceof AccessTokenActorType ? actor.type.authorizer?.type?.user : undefined);
req.token = token;
next();
};
module.exports = configurable_auth;
================================================
FILE: src/backend/src/middleware/featureflag.js
================================================
/*
* 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 .
*/
const APIError = require('../api/APIError');
const { Context } = require('../util/context');
const featureflag = options => async (req, res, next) => {
const { feature } = options;
const context = Context.get();
const services = context.get('services');
const svc_featureFlag = services.get('feature-flag');
if ( ! await svc_featureFlag.check({
actor: req.actor,
}, feature) ) {
const e = APIError.create('forbidden');
e.write(res);
return;
}
next();
};
module.exports = featureflag;
================================================
FILE: src/backend/src/middleware/measure.js
================================================
const { pausing_tee } = require('../util/streamutil');
const putility = require('@heyputer/putility');
const _intercept_req = ({ data, req, next }) => {
if ( ! req.readable ) {
return next();
}
try {
const [req_monitor, req_pass] = pausing_tee(req, 2);
req_monitor.on('data', (chunk) => {
data.sz_incoming += chunk.length;
});
const replaces = ['readable', 'pipe', 'on', 'once', 'removeListener'];
for ( const replace of replaces ) {
const replacement = req_pass[replace];
Object.defineProperty(req, replace, {
get () {
if ( typeof replacement === 'function' ) {
return replacement.bind(req_pass);
}
return replacement;
},
});
}
} catch (e) {
console.error(e);
return next();
}
};
const _intercept_res = ({ data, res, next }) => {
if ( ! res.writable ) {
return next();
}
try {
const org_write = res.write;
const org_end = res.end;
// Override the `write` method
res.write = function (chunk, ...args) {
if ( Buffer.isBuffer(chunk) ) {
data.sz_outgoing += chunk.length;
} else if ( typeof chunk === 'string' ) {
data.sz_outgoing += Buffer.byteLength(chunk);
}
return org_write.apply(res, [chunk, ...args]);
};
// Override the `end` method
res.end = function (chunk, ...args) {
if ( chunk ) {
if ( Buffer.isBuffer(chunk) ) {
data.sz_outgoing += chunk.length;
} else if ( typeof chunk === 'string' ) {
data.sz_outgoing += Buffer.byteLength(chunk);
}
}
const result = org_end.apply(res, [chunk, ...args]);
return result;
};
} catch (e) {
console.error(e);
return next();
}
};
function measure () {
return async (req, res, next) => {
const data = {
sz_incoming: 0,
sz_outgoing: 0,
};
_intercept_req({ data, req });
_intercept_res({ data, res });
req.measurements = new putility.libs.promise.TeePromise();
// Wait for the request to finish processing
res.on('finish', () => {
req.measurements.resolve(data);
// console.log(`Incoming Data: ${data.sz_incoming} bytes`);
// console.log(`Outgoing Data: ${data.sz_outgoing} bytes`); // future
});
next();
};
}
module.exports = measure;
================================================
FILE: src/backend/src/middleware/subdomain.js
================================================
/*
* 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 .
*/
/**
* This middleware checks the subdomain, and if the subdomain doesn't
* match it calls `next('route')` to skip the current route.
* Be sure to use this before any middleware that might erroneously
* block the request.
*
* @param {string|string[]} allowedSubdomains - The subdomain to allow;
* if an array, any of the subdomains in the array will be allowed.
*
* @returns {function} - An express middleware function
*/
const subdomain = allowedSubdomains => {
if ( ! Array.isArray(allowedSubdomains) ) {
allowedSubdomains = [allowedSubdomains];
}
return async (req, res, next) => {
// Note: at the time of implementing this, there is a config
// option called `experimental_no_subdomain` that is designed
// to lie and tell us the subdomain is `api` when it's not.
const actual_subdomain = require('../helpers').subdomain(req);
if ( ! allowedSubdomains.includes(actual_subdomain) ) {
next('route');
return;
}
next();
};
};
module.exports = subdomain;
================================================
FILE: src/backend/src/middleware/verified.js
================================================
/*
* 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 .
*/
const config = require('../config');
const verified = async (req, res, next) => {
if ( ! config.strict_email_verification_required ) {
next();
return;
}
if ( ! req.user.requires_email_confirmation ) {
next();
return;
}
if ( req.user.email_confirmed ) {
next();
return;
}
res.status(400).send({
code: 'account_is_not_verified',
message: 'Account is not verified',
});
};
module.exports = verified;
================================================
FILE: src/backend/src/modules/ai/PuterAIChatModule.js
================================================
/*
* 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 .
*/
import { AdvancedBase } from '@heyputer/putility';
import config from '../../config.js';
import { AIInterfaceService } from '../../services/ai/AIInterfaceService.js';
import { AIChatService } from '../../services/ai/chat/AIChatService.js';
import { AIImageGenerationService } from '../../services/ai/image/AIImageGenerationService.js';
import { AWSTextractService } from '../../services/ai/ocr/AWSTextractService.js';
import { ElevenLabsVoiceChangerService } from '../../services/ai/sts/ElevenLabsVoiceChangerService.js';
import { OpenAISpeechToTextService } from '../../services/ai/stt/OpenAISpeechToTextService.js';
import { AWSPollyService } from '../../services/ai/tts/AWSPollyService.js';
import { ElevenLabsTTSService } from '../../services/ai/tts/ElevenLabsTTSService.js';
import { OpenAITTSService } from '../../services/ai/tts/OpenAITTSService.js';
import { TogetherVideoGenerationService } from '../../services/ai/video/TogetherVideoGenerationService/TogetherVideoGenerationService.js';
import { OpenAIVideoGenerationService } from '../../services/ai/video/OpenAIVideoGenerationService/OpenAIVideoGenerationService.js';
// import { AIVideoGenerationService } from '../../services/ai/video/AIVideoGenerationService.js';
/**
* PuterAIModule class extends AdvancedBase to manage and register various AI services.
* This module handles the initialization and registration of multiple AI-related services
* including text processing, speech synthesis, chat completion, and image generation.
* Services are conditionally registered based on configuration settings, allowing for
* flexible deployment with different AI providers like AWS, OpenAI, Claude, Together AI,
* Mistral, Groq, and XAI.
* @extends AdvancedBase
*/
export class PuterAIModule extends AdvancedBase {
/**
* Module for managing AI-related services in the Puter platform
* Extends AdvancedBase to provide core functionality
* Handles registration and configuration of various AI services like OpenAI, Claude, AWS services etc.
*/
async install (context) {
const services = context.get('services');
services.registerService('__ai-interfaces', AIInterfaceService);
// completion ai service
services.registerService('ai-chat', AIChatService);
// image generation ai service
services.registerService('ai-image', AIImageGenerationService);
// video generation ai service
// services.registerService('ai-video', AIVideoGenerationService);
// TODO DS: centralize other service types too
// TODO: services should govern their own availability instead of the module deciding what to register
if ( config?.services?.['aws-textract']?.aws ) {
services.registerService('aws-textract', AWSTextractService);
}
if ( config?.services?.['aws-polly']?.aws ) {
services.registerService('aws-polly', AWSPollyService);
}
if ( config?.services?.['elevenlabs'] || config?.elevenlabs ) {
services.registerService('elevenlabs-tts', ElevenLabsTTSService);
services.registerService('elevenlabs-voice-changer', ElevenLabsVoiceChangerService);
}
if ( config?.services?.openai || config?.openai ) {
services.registerService('openai-tts', OpenAITTSService);
services.registerService('openai-speech2txt', OpenAISpeechToTextService);
// TODO DS: move to video service
services.registerService('openai-video-generation', OpenAIVideoGenerationService);
}
if ( config?.services?.['together-ai'] ) {
// TODO DS: move to video service
services.registerService('together-video-generation', TogetherVideoGenerationService);
}
}
}
================================================
FILE: src/backend/src/modules/apps/AppIconService.js
================================================
/*
* 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 .
*/
import { createRequire } from 'node:module';
import config from '../../config.js';
import { APP_ICONS_SUBDOMAIN } from '../../consts/app-icons.js';
import { HLWrite } from '../../filesystem/hl_operations/hl_write.js';
import { LLMkdir } from '../../filesystem/ll_operations/ll_mkdir.js';
import { LLRead } from '../../filesystem/ll_operations/ll_read.js';
import { NodePathSelector } from '../../filesystem/node/selectors.js';
import { get_app } from '../../helpers.js';
import BaseService from '../../services/BaseService.js';
import { DB_READ, DB_WRITE } from '../../services/database/consts.js';
import { Endpoint } from '../../util/expressutil.js';
import { buffer_to_stream, stream_to_buffer } from '../../util/streamutil.js';
import { AppRedisCacheSpace } from './AppRedisCacheSpace.js';
import DEFAULT_APP_ICON from './default-app-icon.js';
const require = createRequire(import.meta.url);
const ICON_SIZES = [16, 32, 64, 128, 256, 512];
const DEFAULT_ICON_SIZE = 128;
const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
const LEGACY_ICON_FILENAME = ({ appUid, size }) => `${appUid}-${size}.png`;
const ORIGINAL_ICON_FILENAME = ({ appUid }) => `${appUid}.png`;
const REDIRECT_MAX_AGE_SIZE = 15 * 60; // 15 min
const REDIRECT_MAX_AGE_ORIGINAL = 60; // 1 min
/**
* AppIconService handles icon generation and serving for apps.
*
* This is done by listening to the `app.new-icon` event which is
* dispatched by AppES. `sharp` is used to resize the images to
* pre-selected sizees in the `ICON_SIZES` constant defined above.
*
* Icons are stored in and served from the `/system/app_icons`
* directory. If the system user does not have this directory,
* it will be created in the consolidation boot phase after
* UserService emits the `user.system-user-ready` event on the
* service container event bus.
*/
export class AppIconService extends BaseService {
static MODULES = {
sharp: require('sharp'),
bmp: require('sharp-bmp'),
ico: require('sharp-ico'),
uuidv4: require('uuid').v4,
};
static ICON_SIZES = ICON_SIZES;
/**
* AppIconService listens to this event to register the
* endpoints /app-icon/:app_uid and /app-icon/:app_uid/:size
* which serve the app icon at the requested size.
*/
async '__on_install.routes' (_, { app }) {
const handler = async (req, res) => {
// Validate parameters
let { app_uid: appUid, size } = req.params;
const resolvedSize = Number(size ?? DEFAULT_ICON_SIZE);
if ( ! ICON_SIZES.includes(resolvedSize) ) {
res.status(400).send('Invalid size');
return;
}
if ( ! appUid.startsWith('app-') ) {
appUid = `app-${appUid}`;
}
const {
stream,
mime,
redirectUrl,
redirectCacheControl,
} = await this.#getIconStream({
appUid,
size: resolvedSize,
allowRedirect: !this.config.no_subdomain,
});
if ( redirectUrl ) {
if ( redirectCacheControl ) {
res.set('Cache-Control', redirectCacheControl);
}
return res.redirect(302, redirectUrl);
}
res.set('Content-Type', mime);
res.set('Cache-Control', 'public, max-age=3600');
stream.pipe(res);
};
Endpoint({
route: '/app-icon/:app_uid',
methods: ['GET'],
handler,
}).attach(app);
Endpoint({
route: '/app-icon/:app_uid/:size',
methods: ['GET'],
handler,
}).attach(app);
}
getSizes () {
return this.constructor.ICON_SIZES;
}
async iconifyApps ({ apps, size }) {
return apps.map(app => {
const iconPath = this.getAppIconPath({
appUid: app.uid ?? app.uuid,
size,
});
if ( iconPath ) {
app.icon = iconPath;
}
return app;
});
}
getAppIconPath ({ appUid, size }) {
const normalizedAppUid = this.normalizeAppUid(appUid);
if ( typeof normalizedAppUid !== 'string' || !normalizedAppUid ) {
return null;
}
const apiBaseUrl = String(config.api_base_url || '').replace(/\/+$/, '');
if ( ! apiBaseUrl ) {
return null;
}
const resolvedSize = Number(size ?? DEFAULT_ICON_SIZE);
if ( ! ICON_SIZES.includes(resolvedSize) ) {
return null;
}
return `${apiBaseUrl}/app-icon/${normalizedAppUid}/${resolvedSize}`;
}
getAppIconEndpointUrl ({ appUid }) {
const normalizedAppUid = this.normalizeAppUid(appUid);
if ( typeof normalizedAppUid !== 'string' || !normalizedAppUid ) {
return null;
}
const apiBaseUrl = String(config.api_base_url || '').replace(/\/+$/, '');
if ( ! apiBaseUrl ) {
return null;
}
return `${apiBaseUrl}/app-icon/${normalizedAppUid}`;
}
normalizeAppUid (appUid) {
if ( typeof appUid !== 'string' ) return appUid;
return appUid.startsWith('app-') ? appUid : `app-${appUid}`;
}
isDataUrl (value) {
return (
typeof value === 'string' &&
value.startsWith('data:') &&
value.includes(',')
);
}
isRawBase64ImageString (value) {
if ( typeof value !== 'string' ) return false;
const trimmed = value.trim();
if ( !trimmed || trimmed.length < 16 ) return false;
if ( ! RAW_BASE64_REGEX.test(trimmed) ) return false;
if ( trimmed.length % 4 !== 0 ) return false;
try {
const decoded = Buffer.from(trimmed, 'base64');
if ( decoded.length === 0 ) return false;
const normalizedInput = trimmed.replace(/=+$/, '');
const reencoded = decoded.toString('base64').replace(/=+$/, '');
return normalizedInput === reencoded;
} catch {
return false;
}
}
normalizeRawBase64ImageString (value) {
if ( typeof value !== 'string' ) return value;
const trimmed = value.trim();
if ( ! this.isRawBase64ImageString(trimmed) ) return value;
return `data:image/png;base64,${trimmed}`;
}
parseAppIconEndpointUrl (iconUrl) {
if ( typeof iconUrl !== 'string' || iconUrl.startsWith('data:') ) {
return null;
}
let pathname;
try {
pathname = new URL(iconUrl, 'http://localhost').pathname;
} catch {
return null;
}
const match = pathname.match(/^\/app-icon\/([^/]+)(?:\/(\d+))?\/?$/);
if ( ! match ) return null;
const size = Number(match[2] ?? DEFAULT_ICON_SIZE);
return {
appUid: this.normalizeAppUid(match[1]),
size,
};
}
isAppIconEndpointUrl (iconUrl) {
return !!this.parseAppIconEndpointUrl(iconUrl);
}
isSameAppIconEndpointUrl ({ iconUrl, appUid, size }) {
const parsed = this.parseAppIconEndpointUrl(iconUrl);
if ( ! parsed ) return false;
return (
parsed.appUid === this.normalizeAppUid(appUid) &&
Number(parsed.size) === Number(size)
);
}
extractPuterSubdomainFromUrl (url) {
if ( typeof url !== 'string' ) return null;
let hostname;
try {
hostname = (new URL(url)).hostname.toLowerCase();
} catch {
return null;
}
const hostingDomains = [
config.static_hosting_domain,
config.static_hosting_domain_alt,
].filter(Boolean).map(v => v.toLowerCase());
for ( const domain of hostingDomains ) {
const suffix = `.${domain}`;
if ( hostname.endsWith(suffix) ) {
const subdomain = hostname.slice(0, hostname.length - suffix.length);
return subdomain || null;
}
}
return null;
}
isPuterSubdomainUrl (url) {
return !!this.extractPuterSubdomainFromUrl(url);
}
getAppIconsBaseUrl () {
if ( this.appIconsBaseUrl !== undefined ) {
return this.appIconsBaseUrl;
}
const host = config.static_hosting_domain || config.static_hosting_domain_alt;
if ( ! host ) {
this.appIconsBaseUrl = null;
return this.appIconsBaseUrl;
}
const protocol = config.protocol || 'https';
this.appIconsBaseUrl = `${protocol}://${APP_ICONS_SUBDOMAIN}.${host}`;
return this.appIconsBaseUrl;
}
getSizedIconUrl ({ appUid, size }) {
const baseUrl = this.getAppIconsBaseUrl();
if ( ! baseUrl ) return null;
const normalizedAppUid = this.normalizeAppUid(appUid);
return `${baseUrl}/${LEGACY_ICON_FILENAME({
appUid: normalizedAppUid,
size,
})}`;
}
getOriginalIconUrl ({ appUid }) {
const baseUrl = this.getAppIconsBaseUrl();
if ( ! baseUrl ) return null;
const normalizedAppUid = this.normalizeAppUid(appUid);
return `${baseUrl}/${ORIGINAL_ICON_FILENAME({
appUid: normalizedAppUid,
})}`;
}
async ensureAppIconsDirectory ({ dirSystem = null } = {}) {
const svcFs = this.services.get('filesystem');
const svcSu = this.services.get('su');
const svcUser = this.services.get('user');
return await svcSu.sudo(async () => {
const dirAppIcons = await svcFs.node(new NodePathSelector('/system/app_icons'));
if ( await dirAppIcons.exists() ) {
this.dir_app_icons = dirAppIcons;
return dirAppIcons;
}
dirSystem = dirSystem || await svcUser.get_system_dir();
if ( ! dirSystem ) {
dirSystem = await svcFs.node(new NodePathSelector('/system'));
}
if ( ! await dirSystem.exists() ) {
return dirAppIcons;
}
const llMkdir = new LLMkdir();
await llMkdir.run({
parent: dirSystem,
name: 'app_icons',
actor: await svcSu.get_system_actor(),
});
this.dir_app_icons = dirAppIcons;
return dirAppIcons;
});
}
async getOriginalIconLookup ({ dirAppIcons, appUid }) {
const normalizedAppUid = this.normalizeAppUid(appUid);
const originalFilename = ORIGINAL_ICON_FILENAME({ appUid: normalizedAppUid });
const flatOriginalNode = await dirAppIcons.getChild(originalFilename);
if ( await flatOriginalNode.exists() ) {
return {
node: flatOriginalNode,
isFlatOriginal: true,
};
}
return {
node: null,
isFlatOriginal: false,
};
}
async ensureAppIconsSubdomain ({ dirAppIcons }) {
const dbSites = this.services.get('database').get(DB_WRITE, 'sites');
const existing = await dbSites.read(
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
[APP_ICONS_SUBDOMAIN],
);
if ( existing[0] ) return existing[0];
const svcSu = this.services.get('su');
const systemUser = await svcSu.get_system_user();
if ( ! systemUser?.id ) return null;
const rootDirId = await dirAppIcons.get('mysql-id');
await dbSites.write(`INSERT ${dbSites.case({
mysql: 'IGNORE',
sqlite: 'OR IGNORE',
})} INTO subdomains (subdomain, user_id, root_dir_id, uuid) VALUES (?, ?, ?, ?)`, [
APP_ICONS_SUBDOMAIN,
systemUser.id,
rootDirId,
`sd-${this.modules.uuidv4()}`,
]);
const rows = await dbSites.read(
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
[APP_ICONS_SUBDOMAIN],
);
return rows[0] ?? null;
}
async readIconNodeBuffer ({ node }) {
const svcSu = this.services.get('su');
const llRead = new LLRead();
const stream = await llRead.run({
fsNode: node,
actor: await svcSu.get_system_actor(),
});
return await stream_to_buffer(stream);
}
async writePngToDir ({ destination_or_parent, filename, output }) {
const svcSu = this.services.get('su');
const sysActor = await svcSu.get_system_actor();
const hlWrite = new HLWrite();
await hlWrite.run({
destination_or_parent,
specified_name: filename,
overwrite: true,
actor: sysActor,
user: sysActor.type.user,
no_thumbnail: true,
file: {
size: output.length,
name: filename,
mimetype: 'image/png',
type: 'image/png',
stream: buffer_to_stream(output),
},
});
}
shouldRedirectIconUrl ({ iconUrl, appUid, size }) {
if ( !iconUrl || this.isDataUrl(iconUrl) ) return false;
const canRedirect =
this.isPuterSubdomainUrl(iconUrl) ||
this.isAppIconEndpointUrl(iconUrl);
if ( ! canRedirect ) return false;
return !this.isSameAppIconEndpointUrl({
iconUrl,
appUid,
size,
});
}
async generateMissingSizeFromOriginal ({ appUid, size }) {
const normalizedAppUid = this.normalizeAppUid(appUid);
const dirAppIcons = await this.ensureAppIconsDirectory();
if ( ! await dirAppIcons.exists() ) return;
const { node: originalNode } = await this.getOriginalIconLookup({
dirAppIcons,
appUid: normalizedAppUid,
});
if ( ! originalNode ) return;
const sizedFilename = LEGACY_ICON_FILENAME({
appUid: normalizedAppUid,
size,
});
const sizedNode = await dirAppIcons.getChild(sizedFilename);
if ( await sizedNode.exists() ) return;
const originalBuffer = await this.readIconNodeBuffer({ node: originalNode });
const output = await this.modules.sharp(originalBuffer)
.resize(size)
.png()
.toBuffer();
await this.writePngToDir({
destination_or_parent: dirAppIcons,
filename: sizedFilename,
output,
});
}
queueMissingSizeFromOriginal ({ appUid, size }) {
if ( ! this.pendingIconSizeJobs ) {
this.pendingIconSizeJobs = new Set();
}
const key = `${this.normalizeAppUid(appUid)}:${size}`;
if ( this.pendingIconSizeJobs.has(key) ) return;
this.pendingIconSizeJobs.add(key);
Promise.resolve()
.then(async () => {
await this.generateMissingSizeFromOriginal({ appUid, size });
})
.catch(error => {
this.errors.report('AppIconService.queueMissingSizeFromOriginal', {
source: error,
appUid,
size,
});
})
.finally(() => {
this.pendingIconSizeJobs.delete(key);
});
}
queueDataUrlIconWrite ({ appUid, dataUrl }) {
const normalizedAppUid = this.normalizeAppUid(appUid);
if ( typeof normalizedAppUid !== 'string' || !normalizedAppUid ) return;
if ( ! this.isDataUrl(dataUrl) ) return;
if ( ! this.pendingDataUrlIconWrites ) {
this.pendingDataUrlIconWrites = new Set();
}
const key = normalizedAppUid;
if ( this.pendingDataUrlIconWrites.has(key) ) return;
this.pendingDataUrlIconWrites.add(key);
Promise.resolve()
.then(async () => {
const data = {
app_uid: normalizedAppUid,
data_url: dataUrl,
};
await this.createAppIcons({
data,
});
if ( typeof data.url === 'string' && data.url ) {
await this.persistConvertedIconUrl({
appUid: normalizedAppUid,
iconUrl: data.url,
});
}
})
.catch(error => {
this.errors?.report('AppIconService.queueDataUrlIconWrite', {
source: error,
appUid: normalizedAppUid,
});
})
.finally(() => {
this.pendingDataUrlIconWrites.delete(key);
});
}
async persistConvertedIconUrl ({ appUid, iconUrl }) {
const normalizedAppUid = this.normalizeAppUid(appUid);
if ( typeof normalizedAppUid !== 'string' || !normalizedAppUid ) return;
if ( typeof iconUrl !== 'string' || !iconUrl ) return;
const svcDb = this.services.get('database');
const dbWrite = svcDb.get(DB_WRITE, 'apps');
await dbWrite.write(
'UPDATE apps SET icon = ? WHERE uid = ? AND icon LIKE \'data:%\' LIMIT 1',
[iconUrl, normalizedAppUid],
);
const dbRead = svcDb.get(DB_READ, 'apps');
const rows = await dbRead.read(
'SELECT id, uid, name FROM apps WHERE uid = ? LIMIT 1',
[normalizedAppUid],
);
const app = rows[0];
if ( app ) {
AppRedisCacheSpace.invalidateCachedApp(app);
} else {
AppRedisCacheSpace.invalidateCachedApp({ uid: normalizedAppUid });
}
const svcEvent = this.services.get('event');
await svcEvent.emit('app.changed', {
app_uid: normalizedAppUid,
action: 'icon-migrated',
});
}
async #getIconStream ({ appIcon, appUid, size, tries = 0, allowRedirect = false }) {
appUid = this.normalizeAppUid(appUid);
const appIconOriginal = appIcon;
if ( appIcon && !this.isDataUrl(appIcon) ) {
appIcon = null;
}
// If there is an icon provided, and it's an SVG, we'll just return it
if ( appIcon ) {
const [metadata, data] = appIcon.split(',');
const inputMime = metadata.split(';')[0].split(':')[1];
// svg icons will be sent as-is
if ( inputMime === 'image/svg+xml' ) {
return {
mime: 'image/svg+xml',
get stream () {
return buffer_to_stream(Buffer.from(data, 'base64'));
},
dataUrl: appIcon,
data_url: appIcon,
};
}
}
let app;
const getAppCached = async () => {
if ( app !== undefined ) return app;
app = await get_app({ uid: appUid });
return app;
};
const getFallbackIcon = async () => {
const app = await getAppCached();
const dbIcon = this.normalizeRawBase64ImageString(app?.icon);
let fallbackIcon = appIcon || dbIcon || DEFAULT_APP_ICON;
if ( ! this.isDataUrl(fallbackIcon) ) {
fallbackIcon = DEFAULT_APP_ICON;
}
if ( this.isDataUrl(dbIcon) && fallbackIcon === dbIcon ) {
this.queueDataUrlIconWrite({
appUid,
dataUrl: dbIcon,
});
}
const [metadata, base64] = fallbackIcon.split(',');
const mime = metadata.split(';')[0].split(':')[1];
const img = Buffer.from(base64, 'base64');
return {
mime,
stream: buffer_to_stream(img),
};
};
const getExternalRedirect = async () => {
if ( ! allowRedirect ) return null;
const appIconUrl = this.shouldRedirectIconUrl({
iconUrl: appIconOriginal,
appUid,
size,
}) ? appIconOriginal : null;
let dbIcon;
if ( ! appIconUrl ) {
dbIcon = (await getAppCached())?.icon;
}
const redirectUrl = [appIconUrl, dbIcon].find(url => this.shouldRedirectIconUrl({
iconUrl: url,
appUid,
size,
}));
if ( ! redirectUrl ) return null;
return { redirectUrl };
};
const dirAppIcons = await this.getAppIcons();
const legacyFilename = LEGACY_ICON_FILENAME({ appUid, size });
const legacyNode = await dirAppIcons.getChild(legacyFilename);
if ( await legacyNode.exists() ) {
if ( allowRedirect ) {
const redirectUrl = this.getSizedIconUrl({ appUid, size });
if ( redirectUrl ) {
return {
redirectUrl,
redirectCacheControl: `public, max-age=${REDIRECT_MAX_AGE_SIZE}`,
};
}
}
try {
const output = await this.readIconNodeBuffer({ node: legacyNode });
return {
mime: 'image/png',
stream: buffer_to_stream(output),
};
} catch (e) {
this.errors.report('AppIconService.get_icon_stream', {
source: e,
});
if ( tries < 1 ) {
// Choose the next size up, or 256 if we're already at 512.
const secondSize = size < 512 ? size * 2 : 256;
return await this.#getIconStream({
appUid,
appIcon: appIconOriginal,
size: secondSize,
tries: tries + 1,
allowRedirect,
});
}
}
}
const {
node: originalNode,
isFlatOriginal,
} = await this.getOriginalIconLookup({ dirAppIcons, appUid });
const hasOriginal = !!originalNode;
if ( hasOriginal ) {
this.queueMissingSizeFromOriginal({ appUid, size });
if ( allowRedirect && isFlatOriginal ) {
const redirectUrl = this.getOriginalIconUrl({ appUid });
if ( redirectUrl ) {
return {
redirectUrl,
redirectCacheControl: `public, max-age=${REDIRECT_MAX_AGE_ORIGINAL}`,
};
}
}
try {
const output = await this.readIconNodeBuffer({ node: originalNode });
return {
mime: 'image/png',
stream: buffer_to_stream(output),
};
} catch (e) {
this.errors.report('AppIconService.get_icon_stream:original-read', {
source: e,
});
}
}
return await getExternalRedirect() || await getFallbackIcon();
}
/**
* Returns an FSNodeContext instance for the app icons
* directory.
*/
async getAppIcons () {
if ( this.dir_app_icons ) {
return this.dir_app_icons;
}
const svcFs = this.services.get('filesystem');
const dirAppIcons = await svcFs.node(new NodePathSelector('/system/app_icons'));
return this.dir_app_icons = dirAppIcons;
}
getSharp ({ metadata, input }) {
const type = metadata.split(';')[0].split(':')[1];
if ( type === 'image/bmp' ) {
return this.modules.bmp.sharpFromBmp(input);
}
const icotypes = ['image/x-icon', 'image/vnd.microsoft.icon'];
if ( icotypes.includes(type) ) {
const sharps = this.modules.ico.sharpsFromIco(input);
return sharps[0];
}
return this.modules.sharp(input);
}
async loadIconSource ({ iconUrl }) {
if ( typeof iconUrl !== 'string' || !iconUrl ) {
return null;
}
iconUrl = this.normalizeRawBase64ImageString(iconUrl);
if ( iconUrl.startsWith('data:') ) {
const [metadata, base64] = iconUrl.split(',');
return {
metadata,
input: Buffer.from(base64, 'base64'),
};
}
try {
const response = await fetch(iconUrl);
if ( ! response.ok ) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return {
input: Buffer.from(await response.arrayBuffer()),
metadata: `data:${response.headers.get('content-type') || 'image/png'};base64`,
};
} catch ( error ) {
this.errors.report('AppIconService.createAppIcons:fetchUrl', {
source: error,
iconUrl,
});
return null;
}
}
/**
* AppIconService listens to this event to create the
* `/system/app_icons` directory if it does not exist,
* and then to register the event listener for `app.new-icon`.
*/
async '__on_user.system-user-ready' () {
const svcSu = this.services.get('su');
const svcUser = this.services.get('user');
const dirSystem = await svcUser.get_system_dir();
// Ensure app icons directory exists
await svcSu.sudo(async () => {
const dirAppIcons = await this.ensureAppIconsDirectory({ dirSystem });
await this.ensureAppIconsSubdomain({ dirAppIcons });
});
// Listen for new app icons
const svcEvent = this.services.get('event');
svcEvent.on('app.new-icon', async (_, data) => {
await this.createAppIcons({ data });
});
}
async createAppIcons ({ data }) {
const svcSu = this.services.get('su');
const dataUrl = data.dataUrl ?? data.data_url;
const appUid = this.normalizeAppUid(data.appUid ?? data.app_uid);
if ( !dataUrl || !appUid ) return;
const source = await this.loadIconSource({ iconUrl: dataUrl });
if ( ! source ) return;
const { input, metadata } = source;
const isInputDataUrl = this.isDataUrl(dataUrl);
await svcSu.sudo(async () => {
const dirAppIcons = await this.ensureAppIconsDirectory();
if ( ! await dirAppIcons.exists() ) {
throw new Error('app icons directory is missing');
}
const sharpInstance = this.getSharp({ metadata, input });
if ( isInputDataUrl ) {
const originalOutput = await sharpInstance.clone()
.png()
.toBuffer();
await this.writePngToDir({
destination_or_parent: dirAppIcons,
filename: ORIGINAL_ICON_FILENAME({ appUid }),
output: originalOutput,
});
const endpointUrl = this.getAppIconEndpointUrl({ appUid });
if ( endpointUrl ) {
data.url = endpointUrl;
}
}
const iconJobs = ICON_SIZES.map(async size => {
const output = await sharpInstance.clone()
.resize(size)
.png()
.toBuffer();
await this.writePngToDir({
destination_or_parent: dirAppIcons,
filename: LEGACY_ICON_FILENAME({ appUid, size }),
output,
});
});
await Promise.all(iconJobs);
});
}
async _init () {
}
}
================================================
FILE: src/backend/src/modules/apps/AppIconService.test.js
================================================
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import config from '../../config.js';
import { AppIconService } from './AppIconService.js';
describe('AppIconService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('URL helpers', () => {
it('extracts a puter subdomain from a static hosting URL', () => {
const service = Object.create(AppIconService.prototype);
// TODO: We might need a better way to do this. A service with no
// initialization is difficult to test.
service.config = {};
const domain = 'site.puter.localhost:4100';
config.load_config({
static_hosting_domain: domain,
static_hosting_domain_alt: 'site.puter.localhost',
});
const result = service.extractPuterSubdomainFromUrl(`https://dev-center-app-id.${domain}/icon.png`);
expect(result).toBe('dev-center-app-id');
});
it('does not redirect when URL is the same app-icon endpoint request', () => {
const service = Object.create(AppIconService.prototype);
const shouldRedirect = service.shouldRedirectIconUrl({
iconUrl: 'https://api.puter.localhost/app-icon/app-123/64',
appUid: 'app-123',
size: 64,
});
expect(shouldRedirect).toBe(false);
});
it('parses app-icon endpoint URLs without size as default size 128', () => {
const service = Object.create(AppIconService.prototype);
const parsed = service.parseAppIconEndpointUrl('https://api.puter.localhost/app-icon/app-123');
expect(parsed).toEqual({
appUid: 'app-123',
size: 128,
});
});
it('normalizes raw base64 icon strings to png data URLs', () => {
const service = Object.create(AppIconService.prototype);
const rawBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
const result = service.normalizeRawBase64ImageString(rawBase64);
expect(result).toBe(`data:image/png;base64,${rawBase64}`);
});
});
describe('createAppIcons', () => {
it('stores original and resized icons in /system/app_icons for data URLs', async () => {
const sudo = vi.fn(async callback => await callback());
const dirAppIcons = {
exists: vi.fn().mockResolvedValue(true),
};
const service = Object.create(AppIconService.prototype);
service.services = {
get: vi.fn(name => (name === 'su' ? { sudo } : null)),
};
service.errors = { report: vi.fn() };
service.ensureAppIconsDirectory = vi.fn().mockResolvedValue(dirAppIcons);
service.getAppIconEndpointUrl = vi.fn().mockReturnValue('https://api.puter.localhost/app-icon/app-abc');
service.loadIconSource = vi.fn().mockResolvedValue({
metadata: 'data:image/png;base64',
input: Buffer.from([1, 2, 3]),
});
service.writePngToDir = vi.fn().mockResolvedValue(undefined);
service.getSharp = vi.fn(() => ({
clone: vi.fn(() => ({
resize: vi.fn().mockReturnThis(),
png: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(Buffer.from([0x89, 0x50, 0x4e, 0x47])),
})),
}));
const data = {
appUid: 'app-abc',
dataUrl: 'data:image/png;base64,AA==',
};
await service.createAppIcons({ data });
expect(service.writePngToDir).toHaveBeenCalledTimes(AppIconService.ICON_SIZES.length + 1);
expect(service.writePngToDir).toHaveBeenCalledWith(expect.objectContaining({
destination_or_parent: dirAppIcons,
filename: 'app-abc.png',
}));
expect(service.writePngToDir).toHaveBeenCalledWith(expect.objectContaining({
destination_or_parent: dirAppIcons,
filename: 'app-abc-64.png',
}));
expect(data.url).toBe('https://api.puter.localhost/app-icon/app-abc');
});
it('queueDataUrlIconWrite persists migrated URL to DB when conversion succeeds', async () => {
const service = Object.create(AppIconService.prototype);
service.errors = { report: vi.fn() };
service.createAppIcons = vi.fn(async ({ data }) => {
data.url = 'https://api.puter.localhost/app-icon/app-abc';
});
service.persistConvertedIconUrl = vi.fn().mockResolvedValue(undefined);
service.queueDataUrlIconWrite({
appUid: 'app-abc',
dataUrl: 'data:image/png;base64,AA==',
});
await Promise.resolve();
await Promise.resolve();
expect(service.createAppIcons).toHaveBeenCalledTimes(1);
expect(service.persistConvertedIconUrl).toHaveBeenCalledWith({
appUid: 'app-abc',
iconUrl: 'https://api.puter.localhost/app-icon/app-abc',
});
});
});
describe('icon URL mapping', () => {
it('builds a legacy app-icon path with normalized app uid', () => {
const service = Object.create(AppIconService.prototype);
const result = service.getAppIconPath({
appUid: 'abc',
size: 64,
});
expect(result).toBe(`${config.api_base_url}/app-icon/app-abc/64`);
});
it('defaults to size 128 when size is not provided', () => {
const service = Object.create(AppIconService.prototype);
const result = service.getAppIconPath({
appUid: 'abc',
});
expect(result).toBe(`${config.api_base_url}/app-icon/app-abc/128`);
});
it('iconifyApps rewrites icons to the legacy app-icon endpoint path', async () => {
const service = Object.create(AppIconService.prototype);
const apps = [
{ uid: 'app-abc', icon: 'data:image/png;base64,AA==' },
{ uuid: 'def', icon: 'https://example.com/icon.png' },
];
const result = await service.iconifyApps({
apps,
size: 128,
});
expect(result[0].icon).toBe(`${config.api_base_url}/app-icon/app-abc/128`);
expect(result[1].icon).toBe(`${config.api_base_url}/app-icon/app-def/128`);
});
it('iconifyApps leaves icon unchanged when app uid is missing', async () => {
const service = Object.create(AppIconService.prototype);
const apps = [{ icon: 'existing-icon' }];
const result = await service.iconifyApps({
apps,
size: 128,
});
expect(result[0].icon).toBe('existing-icon');
});
});
});
================================================
FILE: src/backend/src/modules/apps/AppInformationService.js
================================================
/*
* 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 .
*/
const { origin_from_url } = require('../../util/urlutil');
const { DB_READ } = require('../../services/database/consts');
const BaseService = require('../../services/BaseService');
const { redisClient } = require('../../clients/redis/redisSingleton');
const { deleteRedisKeys } = require('../../clients/redis/deleteRedisKeys.js');
const { setRedisCacheValue } = require('../../clients/redis/cacheUpdate.js');
const { AppRedisCacheSpace } = require('./AppRedisCacheSpace.js');
const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias';
const APP_UID_ALIAS_REVERSE_KEY_PREFIX = 'app:canonicalUidAliasReverse';
/**
* @class AppInformationService
* @description
* The AppInformationService class manages application-related information,
* including caching, statistical data, and tags for applications within the Puter ecosystem.
* It provides methods for refreshing application data, managing app statistics,
* and handling tags associated with apps. This service is crucial for maintaining
* up-to-date information about applications, facilitating features like app listings
* and tag-based app discovery.
*/
class AppInformationService extends BaseService {
static LOG_DEBUG = true;
_construct () {
this.tags = {};
// MySQL date format mapping for different groupings
this.mysqlDateFormats = {
'hour': '%Y-%m-%d %H:00:00',
'day': '%Y-%m-%d',
'week': '%Y-%U',
'month': '%Y-%m',
'year': '%Y',
};
// ClickHouse date format mapping for different groupings
this.clickhouseGroupByFormats = {
'hour': 'toStartOfHour(fromUnixTimestamp(ts))',
'day': 'toStartOfDay(fromUnixTimestamp(ts))',
'week': 'toStartOfWeek(fromUnixTimestamp(ts))',
'month': 'toStartOfMonth(fromUnixTimestamp(ts))',
'year': 'toStartOfYear(fromUnixTimestamp(ts))',
};
}
'__on_boot.consolidation' () {
const svc_event = this.services.get('event');
svc_event.on('app.rename', (_, { app_uid: appUid, old_name: oldName }) => {
this.invalidateAppCache({ appUid, oldName }).catch((e) => {
this.log.error('failed invalidating app cache after app.rename', { appUid, oldName, error: e });
});
});
svc_event.on('app.changed', (_, { app_uid: appUid, app }) => {
this.invalidateAppCache({ appUid, app }).catch((e) => {
this.log.error('failed invalidating app cache after app.changed', { appUid, error: e });
});
});
(async () => {
try {
await this._refresh_app_stats();
} catch (e) {
console.error('Some app cache portion failed to populate:', e);
}
setInterval(async () => {
try {
await this._refresh_app_stats();
} catch (e) {
console.error('App stats cache failed to update:', e);
}
}, 15.314 * 60 * 1000);
})();
}
async invalidateAppCache ({ appUid, oldName, app }) {
let resolvedApp = app ?? null;
if ( !resolvedApp && appUid ) {
resolvedApp = await AppRedisCacheSpace.getCachedApp({
lookup: 'uid',
value: appUid,
});
}
if ( !resolvedApp && appUid ) {
const db = this.services.get('database').get(DB_READ, 'apps');
resolvedApp = (await db.read(
'SELECT id, uid, name FROM apps WHERE uid = ? LIMIT 1',
[appUid],
))[0] ?? null;
}
if ( resolvedApp ) {
await AppRedisCacheSpace.invalidateCachedApp(resolvedApp, {
includeStats: true,
});
} else if ( appUid ) {
await Promise.all([
deleteRedisKeys([
AppRedisCacheSpace.key({
lookup: 'uid',
value: appUid,
rawIcon: true,
}),
AppRedisCacheSpace.key({
lookup: 'uid',
value: appUid,
rawIcon: false,
}),
]),
AppRedisCacheSpace.invalidateAppStats(appUid),
]);
}
if ( oldName ) {
await AppRedisCacheSpace.invalidateCachedAppName(oldName);
}
const svc_event = this.services.get('event');
await svc_event.emit('apps.invalidate', {
app: resolvedApp ?? app ?? { uid: appUid, name: oldName },
});
}
/**
* Retrieves and returns statistical data for a specific application over different time periods.
*
* This method fetches various metrics such as the number of times the app has been opened,
* the count of unique users who have opened the app, and the number of referrals attributed to the app.
* It supports different time periods such as today, yesterday, past 7 days, past 30 days, and all time.
*
* @param {string} app_uid - The unique identifier for the application.
* @param {Object} [options] - Optional parameters to customize the query
* @param {string} [options.period='all'] - Time period for stats: 'today', 'yesterday', '7d', '30d', 'this_month', 'last_month', 'this_year', 'last_year', '12m', 'all'
* @param {string} [options.grouping=undefined] - Time grouping for stats: 'hour', 'day', 'week', 'month', 'year'
* @returns {Promise} An object containing:
* - {Object} open_count - Open counts for different time periods
* - {Object} user_count - Uniqu>e user counts for different time periods
* - {number|null} referral_count - The number of referrals (all-time only)
*/
async get_stats (app_uid, options = {}) {
let period = options.period ?? 'all';
let stats_grouping = options.grouping;
let app_creation_ts = options.created_at;
const parse_cached_int = (value) => {
if ( value === null || value === undefined ) return null;
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? null : parsed;
};
// Check cache first if period is 'all' and no grouping is requested
if ( period === 'all' && !stats_grouping ) {
const key_open_count = AppRedisCacheSpace.openCountKey(app_uid);
const key_user_count = AppRedisCacheSpace.userCountKey(app_uid);
const key_referral_count = AppRedisCacheSpace.referralCountKey(app_uid);
const [cached_open_count, cached_user_count, cached_referral_count] = await Promise.all([
redisClient.get(key_open_count),
redisClient.get(key_user_count),
redisClient.get(key_referral_count),
]);
const cached_open_count_parsed = parse_cached_int(cached_open_count);
const cached_user_count_parsed = parse_cached_int(cached_user_count);
if ( cached_open_count_parsed !== null && cached_user_count_parsed !== null ) {
return {
open_count: cached_open_count_parsed,
user_count: cached_user_count_parsed,
referral_count: parse_cached_int(cached_referral_count),
};
}
}
const db = this.services.get('database').get(DB_READ, 'apps');
const getTimeRange = (period) => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
switch ( period ) {
case 'today':
return {
start: today.getTime(),
end: now.getTime(),
};
case 'yesterday': {
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
return {
start: yesterday.getTime(),
end: today.getTime() - 1,
};
}
case '7d': {
const weekAgo = new Date(now);
weekAgo.setDate(weekAgo.getDate() - 7);
return {
start: weekAgo.getTime(),
end: now.getTime(),
};
}
case '30d': {
const monthAgo = new Date(now);
monthAgo.setDate(monthAgo.getDate() - 30);
return {
start: monthAgo.getTime(),
end: now.getTime(),
};
}
case 'this_week': {
const firstDayOfWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay());
return {
start: firstDayOfWeek.getTime(),
end: now.getTime(),
};
}
case 'last_week': {
const firstDayOfLastWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay() - 7);
const firstDayOfThisWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay());
return {
start: firstDayOfLastWeek.getTime(),
end: firstDayOfThisWeek.getTime() - 1,
};
}
case 'this_month': {
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return {
start: firstDayOfMonth.getTime(),
end: now.getTime(),
};
}
case 'last_month': {
const firstDayOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const firstDayOfThisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return {
start: firstDayOfLastMonth.getTime(),
end: firstDayOfThisMonth.getTime() - 1,
};
}
case 'this_year': {
const firstDayOfYear = new Date(now.getFullYear(), 0, 1);
return {
start: firstDayOfYear.getTime(),
end: now.getTime(),
};
}
case 'last_year': {
const firstDayOfLastYear = new Date(now.getFullYear() - 1, 0, 1);
const firstDayOfThisYear = new Date(now.getFullYear(), 0, 1);
return {
start: firstDayOfLastYear.getTime(),
end: firstDayOfThisYear.getTime() - 1,
};
}
case '12m': {
const twelveMonthsAgo = new Date(now);
twelveMonthsAgo.setMonth(twelveMonthsAgo.getMonth() - 12);
return {
start: twelveMonthsAgo.getTime(),
end: now.getTime(),
};
}
case 'all': {
const start = new Date(app_creation_ts);
return {
start: start.getTime(),
end: now.getTime(),
};
}
default:
return null;
}
};
const timeRange = getTimeRange(period);
// Handle time-based grouping if stats_grouping is specified
if ( stats_grouping ) {
const timeFormat = this.mysqlDateFormats[stats_grouping];
if ( ! timeFormat ) {
throw new Error(`Invalid stats_grouping: ${stats_grouping}. Supported values are: hour, day, week, month, year`);
}
// Generate all periods for the time range
const allPeriods = this.generateAllPeriods(
new Date(timeRange.start),
new Date(timeRange.end),
stats_grouping,
);
if ( global.clickhouseClient ) {
const groupByFormat = this.clickhouseGroupByFormats[stats_grouping];
const timeCondition = timeRange ?
`AND ts >= ${Math.floor(timeRange.start / 1000)} AND ts < ${Math.floor(timeRange.end / 1000)}` : '';
const [openResult, userResult] = await Promise.all([
global.clickhouseClient.query({
query: `
SELECT
${groupByFormat} as period,
COUNT(_id) as count
FROM app_opens
WHERE app_uid = '${app_uid}'
${timeCondition}
GROUP BY period
ORDER BY period
`,
format: 'JSONEachRow',
}),
global.clickhouseClient.query({
query: `
SELECT
${groupByFormat} as period,
COUNT(DISTINCT user_id) as count
FROM app_opens
WHERE app_uid = '${app_uid}'
${timeCondition}
GROUP BY period
ORDER BY period
`,
format: 'JSONEachRow',
}),
]);
const openRows = await openResult.json();
const userRows = await userResult.json();
// Ensure counts are properly parsed as integers
const processedOpenRows = openRows.map(row => ({
period: new Date(row.period),
count: parseInt(row.count),
}));
const processedUserRows = userRows.map(row => ({
period: new Date(row.period),
count: parseInt(row.count),
}));
// Calculate totals from the processed rows
const totalOpenCount = processedOpenRows.reduce((sum, row) => sum + row.count, 0);
const totalUserCount = processedUserRows.reduce((sum, row) => sum + row.count, 0);
// Generate all periods and merge with actual data
const allPeriods = this.generateAllPeriods(
new Date(timeRange.start),
new Date(timeRange.end),
stats_grouping,
);
const completeOpenStats = this.mergeWithGeneratedPeriods(processedOpenRows, allPeriods, stats_grouping);
const completeUserStats = this.mergeWithGeneratedPeriods(processedUserRows, allPeriods, stats_grouping);
return {
open_count: totalOpenCount,
user_count: totalUserCount,
grouped_stats: {
open_count: completeOpenStats,
user_count: completeUserStats,
},
referral_count: period === 'all'
? parse_cached_int(await redisClient.get(AppRedisCacheSpace.referralCountKey(app_uid)))
: null,
};
}
else {
// MySQL queries for grouped stats
const queryParams = timeRange ?
[app_uid, timeRange.start / 1000, timeRange.end / 1000] :
[app_uid];
const [openResult, userResult] = await Promise.all([
db.read(`
SELECT ${db.case({
mysql: `DATE_FORMAT(FROM_UNIXTIME(ts/1000), '${timeFormat}') as period, `,
sqlite: `STRFTIME('%Y-%m-%d %H', datetime(ts/1000, 'unixepoch'), '${timeFormat}') as period, `,
})
}
COUNT(_id) as count
FROM app_opens
WHERE app_uid = ?
${timeRange ? 'AND ts >= ? AND ts < ?' : ''}
GROUP BY period
ORDER BY period
`, queryParams),
db.read(`
SELECT ${db.case({
mysql: `DATE_FORMAT(FROM_UNIXTIME(ts/1000), '${timeFormat}') as period, `,
sqlite: `STRFTIME('%Y-%m-%d %H', datetime(ts/1000, 'unixepoch'), '${timeFormat}') as period, `,
})
}
COUNT(DISTINCT user_id) as count
FROM app_opens
WHERE app_uid = ?
${timeRange ? 'AND ts >= ? AND ts < ?' : ''}
GROUP BY period
ORDER BY period
`, queryParams),
]);
// Calculate totals
const totalOpenCount = openResult.reduce((sum, row) => sum + parseInt(row.count), 0);
const totalUserCount = userResult.reduce((sum, row) => sum + parseInt(row.count), 0);
// Convert MySQL results to the same format as needed
const openRows = openResult.map(row => ({
period: row.period,
count: parseInt(row.count),
}));
const userRows = userResult.map(row => ({
period: row.period,
count: parseInt(row.count),
}));
// Merge with generated periods to include zero-value periods
const completeOpenStats = this.mergeWithGeneratedPeriods(openRows, allPeriods, stats_grouping);
const completeUserStats = this.mergeWithGeneratedPeriods(userRows, allPeriods, stats_grouping);
return {
open_count: totalOpenCount,
user_count: totalUserCount,
grouped_stats: {
open_count: completeOpenStats,
user_count: completeUserStats,
},
referral_count: period === 'all'
? parse_cached_int(await redisClient.get(AppRedisCacheSpace.referralCountKey(app_uid)))
: null,
};
}
}
// Handle non-grouped stats
if ( global.clickhouseClient ) {
const openCountQuery = timeRange
? `SELECT COUNT(_id) AS open_count FROM app_opens
WHERE app_uid = '${app_uid}'
AND ts >= ${Math.floor(timeRange.start / 1000)}
AND ts < ${Math.floor(timeRange.end / 1000)}`
: `SELECT COUNT(_id) AS open_count FROM app_opens
WHERE app_uid = '${app_uid}'`;
const userCountQuery = timeRange
? `SELECT COUNT(DISTINCT user_id) AS uniqueUsers FROM app_opens
WHERE app_uid = '${app_uid}'
AND ts >= ${Math.floor(timeRange.start / 1000)}
AND ts < ${Math.floor(timeRange.end / 1000)}`
: `SELECT COUNT(DISTINCT user_id) AS uniqueUsers FROM app_opens
WHERE app_uid = '${app_uid}'`;
const [openResult, userResult] = await Promise.all([
global.clickhouseClient.query({
query: openCountQuery,
format: 'JSONEachRow',
}),
global.clickhouseClient.query({
query: userCountQuery,
format: 'JSONEachRow',
}),
]);
const openRows = await openResult.json();
const userRows = await userResult.json();
const results = {
open_count: parseInt(openRows[0].open_count),
user_count: parseInt(userRows[0].uniqueUsers),
referral_count: period === 'all'
? parse_cached_int(await redisClient.get(AppRedisCacheSpace.referralCountKey(app_uid)))
: null,
};
// Cache the results if period is 'all'
if ( period === 'all' ) {
const key_open_count = AppRedisCacheSpace.openCountKey(app_uid);
const key_user_count = AppRedisCacheSpace.userCountKey(app_uid);
void Promise.all([
setRedisCacheValue(key_open_count, results.open_count),
setRedisCacheValue(key_user_count, results.user_count),
]);
}
return results;
} else {
// Regular MySQL queries for non-grouped stats
const baseOpenQuery = 'SELECT COUNT(_id) AS open_count FROM app_opens WHERE app_uid = ?';
const baseUserQuery = 'SELECT COUNT(DISTINCT user_id) AS user_count FROM app_opens WHERE app_uid = ?';
const generateQuery = (baseQuery, timeRange) => {
if ( ! timeRange ) return baseQuery;
return `${baseQuery} AND ts >= ? AND ts < ?`;
};
const openQuery = generateQuery(baseOpenQuery, timeRange);
const userQuery = generateQuery(baseUserQuery, timeRange);
const queryParams = timeRange ? [app_uid, timeRange.start, timeRange.end] : [app_uid];
const [openResult, userResult] = await Promise.all([
db.read(openQuery, queryParams),
db.read(userQuery, queryParams),
]);
const results = {
open_count: parseInt(openResult[0].open_count),
user_count: parseInt(userResult[0].user_count),
referral_count: period === 'all'
? parse_cached_int(await redisClient.get(AppRedisCacheSpace.referralCountKey(app_uid)))
: null,
};
// Cache the results if period is 'all'
if ( period === 'all' ) {
const key_open_count = AppRedisCacheSpace.openCountKey(app_uid);
const key_user_count = AppRedisCacheSpace.userCountKey(app_uid);
void Promise.all([
setRedisCacheValue(key_open_count, results.open_count),
setRedisCacheValue(key_user_count, results.user_count),
]);
}
return results;
}
}
/**
* Refreshes the cache of app statistics including open and user counts.
*
* @notes
* - This method logs a tick event for performance monitoring.
*
* @async
* @returns {Promise} A promise that resolves when the cache refresh operation is complete.
*/
async _refresh_app_stats () {
this.log.tick('refresh app stats');
const db = this.services.get('database').get(DB_READ, 'apps');
let openCountMap;
let userCountMap;
if ( global.clickhouseClient ) {
const [openResult, userResult] = await Promise.all([
global.clickhouseClient.query({
query: `
SELECT app_uid, COUNT(_id) AS open_count
FROM app_opens
GROUP BY app_uid
`,
format: 'JSONEachRow',
}),
global.clickhouseClient.query({
query: `
SELECT app_uid, COUNT(DISTINCT user_id) AS user_count
FROM app_opens
GROUP BY app_uid
`,
format: 'JSONEachRow',
}),
]);
const openRows = await openResult.json();
const userRows = await userResult.json();
openCountMap = new Map(openRows.map(row => [row.app_uid, parseInt(row.open_count, 10)]));
userCountMap = new Map(userRows.map(row => [row.app_uid, parseInt(row.user_count, 10)]));
} else {
const [openCounts, userCounts] = await Promise.all([
db.read(`
SELECT app_uid, COUNT(_id) AS open_count
FROM app_opens
GROUP BY app_uid
`),
db.read(`
SELECT app_uid, COUNT(DISTINCT user_id) AS user_count
FROM app_opens
GROUP BY app_uid
`),
]);
openCountMap = new Map(openCounts.map(row => [row.app_uid, row.open_count]));
userCountMap = new Map(userCounts.map(row => [row.app_uid, row.user_count]));
}
// Get all app UIDs and update the cache (apps list lives in MySQL)
const apps = await db.read('SELECT uid FROM apps');
for ( const app of apps ) {
const key_open_count = AppRedisCacheSpace.openCountKey(app.uid);
const key_user_count = AppRedisCacheSpace.userCountKey(app.uid);
// Background refresh writes should stay local to avoid broadcast churn.
void Promise.all([
setRedisCacheValue(key_open_count, openCountMap.get(app.uid) ?? 0, { emitEvent: false }),
setRedisCacheValue(key_user_count, userCountMap.get(app.uid) ?? 0, { emitEvent: false }),
]);
}
}
/**
* Refreshes the cache of app referral statistics.
*
* This method queries the database for user counts referred by each app's origin URL
* and updates the cache with the referral counts for each app.
*
* @notes
* - This method logs a tick event for performance monitoring.
*
* @async
* @returns {Promise} A promise that resolves when the cache refresh operation is complete.
*/
async _refresh_app_stat_referrals () {
this.log.tick('refresh app stat referrals');
const db = this.services.get('database').get(DB_READ, 'apps');
const apps = await db.read('SELECT uid, index_url FROM apps');
// First, build a map of valid app origins to UIDs
const validApps = [];
const svc_auth = this.services.get('auth');
for ( const app of apps ) {
const origin = origin_from_url(app.index_url);
// only count the referral if the origin hashes to the app's uid
let expected_uid;
try {
expected_uid = await svc_auth.app_uid_from_origin(origin);
} catch (e) {
// This happens if the app origin isn't valid
continue;
}
if ( expected_uid !== app.uid ) {
continue;
}
validApps.push({ uid: app.uid, origin });
}
if ( validApps.length === 0 ) {
return;
}
// Build a single query to get all referral counts
const likeConditions = validApps.map(() => 'referrer LIKE ?').join(' OR ');
const queryParams = validApps.map(app => `${app.origin}%`);
const referralResults = await db.read(`
SELECT
referrer,
COUNT(id) as referral_count
FROM user
WHERE ${likeConditions}
GROUP BY referrer
`, queryParams);
// Create a map to store referral counts by origin
const referralMap = new Map();
for ( const result of referralResults ) {
// Find which app this referrer belongs to
for ( const app of validApps ) {
if ( result.referrer.startsWith(app.origin) ) {
const currentCount = referralMap.get(app.uid) || 0;
referralMap.set(app.uid, currentCount + parseInt(result.referral_count));
break;
}
}
}
// Update cache with results
for ( const app of validApps ) {
const key_referral_count = AppRedisCacheSpace.referralCountKey(app.uid);
const count = referralMap.get(app.uid) || 0;
// Background refresh writes should stay local to avoid broadcast churn.
await setRedisCacheValue(key_referral_count, count, { emitEvent: false });
}
this.log.info('DONE refresh app stat referrals');
}
/**
* Deletes an application from the system.
*
* This method performs the following actions:
* - Retrieves the app data from cache or database if not provided.
* - Deletes the app record from the database.
* - Removes the app from all relevant caches (by name, id, and uid).
* - Removes the app from any associated tags.
*
* @param {string} app_uid - The unique identifier of the app to be deleted.
* @param {Object} [app] - The app object, if already fetched. If not provided, it will be retrieved.
* @param {Object} [options] - Optional delete behavior flags.
* @throws {Error} If the app is not found in either cache or database.
* @returns {Promise} A promise that resolves when the app has been successfully deleted.
*/
async delete_app (app_uid, app, options = {}) {
const db = this.services.get('database').get(DB_READ, 'apps');
if ( ! app ) {
app = await AppRedisCacheSpace.getCachedApp({
lookup: 'uid',
value: app_uid,
});
}
if ( ! app ) {
app = (await db.read(
'SELECT * FROM apps WHERE uid = ?',
[app_uid],
))[0];
}
if ( ! app ) {
throw new Error('app not found');
}
const associationRows = await db.read(
'SELECT type FROM app_filetype_association WHERE app_id = ?',
[app.id],
);
await db.write(
'DELETE FROM apps WHERE uid = ? LIMIT 1',
[app_uid],
);
if ( ! options.preserveCanonicalUidAlias ) {
await this.cleanupCanonicalAppUidAliases_(app_uid);
}
// remove from caches
AppRedisCacheSpace.invalidateCachedApp(app, {
includeStats: true,
});
const associationKeys = associationRows
.map(row => String(row.type ?? '').trim().toLowerCase().replace(/^\./, ''))
.filter(Boolean)
.map(ext => AppRedisCacheSpace.associationAppsKey(ext));
if ( associationKeys.length ) {
await deleteRedisKeys(associationKeys);
}
// remove from tags
const app_tags = (app.tags ?? '').split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
for ( const tag of app_tags ) {
if ( ! this.tags[tag] ) continue;
const index = this.tags[tag].indexOf(app_uid);
if ( index >= 0 ) {
this.tags[tag].splice(index, 1);
}
}
const svc_event = this.services.get('event');
await svc_event.emit('app.changed', {
app_uid: app.uid,
action: 'deleted',
app,
});
}
buildCanonicalAppUidAliasKey_ (appUid) {
return `${APP_UID_ALIAS_KEY_PREFIX}:${appUid}`;
}
buildCanonicalAppUidAliasReverseKey_ (canonicalAppUid) {
return `${APP_UID_ALIAS_REVERSE_KEY_PREFIX}:${canonicalAppUid}`;
}
normalizeCanonicalAliasUidList_ (value) {
if ( ! Array.isArray(value) ) return [];
const normalizedList = [];
const seen = new Set();
for ( const item of value ) {
if ( typeof item !== 'string' || !item ) continue;
if ( seen.has(item) ) continue;
seen.add(item);
normalizedList.push(item);
}
return normalizedList;
}
async cleanupCanonicalAppUidAliases_ (appUid) {
if ( typeof appUid !== 'string' || !appUid ) return;
const kvStore = this.services.get('puter-kvstore');
const suService = this.services.get('su');
if ( !kvStore || typeof kvStore.get !== 'function' || typeof kvStore.del !== 'function' ) return;
if ( !suService || typeof suService.sudo !== 'function' ) return;
const selfAliasKey = this.buildCanonicalAppUidAliasKey_(appUid);
const reverseKey = this.buildCanonicalAppUidAliasReverseKey_(appUid);
try {
await suService.sudo(async () => {
const reverseValue = await kvStore.get({ key: reverseKey });
const reverseAliases = this.normalizeCanonicalAliasUidList_(reverseValue);
const deleteOps = [
kvStore.del({ key: selfAliasKey }),
kvStore.del({ key: reverseKey }),
];
for ( const oldUid of reverseAliases ) {
deleteOps.push(kvStore.del({
key: this.buildCanonicalAppUidAliasKey_(oldUid),
}));
}
await Promise.all(deleteOps);
});
} catch {
// KV cleanup is best-effort.
}
}
// Helper function to generate array of all periods between start and end dates
generateAllPeriods (startDate, endDate, grouping) {
const periods = [];
let currentDate = new Date(startDate);
// ???: In local debugging, `currentDate` evaluates to `Invalid Date`.
// Does this work in prod?
while ( currentDate <= endDate ) {
let period;
switch ( grouping ) {
case 'hour':
period = `${currentDate.toISOString().slice(0, 13)}:00:00`;
currentDate.setHours(currentDate.getHours() + 1);
break;
case 'day':
period = currentDate.toISOString().slice(0, 10);
currentDate.setDate(currentDate.getDate() + 1);
break;
case 'week': {
// Get the ISO week number
const weekNum = String(this.getWeekNumber(currentDate)).padStart(2, '0');
period = `${currentDate.getFullYear()}-${weekNum}`;
currentDate.setDate(currentDate.getDate() + 7);
break;
}
case 'month':
period = currentDate.toISOString().slice(0, 7);
currentDate.setMonth(currentDate.getMonth() + 1);
break;
case 'year':
period = currentDate.getFullYear().toString();
currentDate.setFullYear(currentDate.getFullYear() + 1);
break;
}
periods.push({ period, count: 0 });
}
return periods;
}
// Helper function to get ISO week number
getWeekNumber (date) {
const target = new Date(date.valueOf());
const dayNumber = (date.getDay() + 6) % 7;
target.setDate(target.getDate() - dayNumber + 3);
const firstThursday = target.valueOf();
target.setMonth(0, 1);
if ( target.getDay() !== 4 ) {
target.setMonth(0, 1 + ((4 - target.getDay()) + 7) % 7);
}
return 1 + Math.ceil((firstThursday - target) / 604800000);
}
// Helper function to merge actual data with generated periods
mergeWithGeneratedPeriods (actualData, allPeriods, stats_grouping) {
// Create a map of period to count from actual data
// First normalize the period format from both MySQL and ClickHouse
const dataMap = new Map(actualData.map(item => {
let period = item.period;
// For ClickHouse results, convert the timestamp to match the expected format
if ( item.period instanceof Date ) {
switch ( stats_grouping ) {
case 'hour':
period = `${item.period.toISOString().slice(0, 13)}:00:00`;
break;
case 'day':
period = item.period.toISOString().slice(0, 10);
break;
case 'week': {
const weekNum = String(this.getWeekNumber(item.period)).padStart(2, '0');
period = `${item.period.getFullYear()}-${weekNum}`;
break;
}
case 'month':
period = item.period.toISOString().slice(0, 7);
break;
case 'year':
period = item.period.getFullYear().toString();
break;
}
}
return [period, parseInt(item.count)];
}));
// Map the generated periods to include actual counts where they exist
return allPeriods.map(periodObj => {
const count = dataMap.get(periodObj.period);
return {
period: periodObj.period,
count: count !== undefined ? count : 0,
};
});
}
}
module.exports = {
AppInformationService,
};
================================================
FILE: src/backend/src/modules/apps/AppPermissionService.js
================================================
const { UserActorType } = require('../../services/auth/Actor');
const { PermissionImplicator, PermissionUtil } = require('../../services/auth/permissionUtils.mjs');
const BaseService = require('../../services/BaseService');
class AppPermissionService extends BaseService {
async _init () {
const svc_permission = this.services.get('permission');
svc_permission.register_implicator(PermissionImplicator.create({
id: 'user-can-grant-read-own-apps',
matcher: permission => {
return permission.startsWith('apps-of-user:') ||
permission.startsWith('subdomains-of-user:');
},
checker: async ({ actor, permission }) => {
if ( ! (actor.type instanceof UserActorType) ) {
return undefined;
}
const parts = PermissionUtil.split(permission);
if ( parts[1] === actor.type.user.uuid ) {
return {};
}
},
}));
}
}
module.exports = {
AppPermissionService,
};
================================================
FILE: src/backend/src/modules/apps/AppRedisCacheSpace.js
================================================
/*
* 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 .
*/
import { redisClient } from '../../clients/redis/redisSingleton.js';
import { deleteRedisKeys } from '../../clients/redis/deleteRedisKeys.js';
const appFullNamespace = 'apps';
const appLookupKeys = ['uid', 'name', 'id'];
const safeParseJson = (value, fallback = null) => {
if ( value === null || value === undefined ) return fallback;
try {
return JSON.parse(value);
} catch (e) {
return fallback;
}
};
const setKey = async (key, value, { ttlSeconds } = {}) => {
if ( ttlSeconds ) {
await redisClient.set(key, value, 'EX', ttlSeconds);
return;
}
await redisClient.set(key, value);
};
const appNamespace = () => appFullNamespace;
const appCacheKey = ({ lookup, value }) => (
`${appNamespace()}:${lookup}:${value}`
);
export const AppRedisCacheSpace = {
key: appCacheKey,
namespace: appNamespace,
keysForApp: (app) => {
if ( ! app ) return [];
return appLookupKeys
.filter(lookup => app[lookup] !== undefined && app[lookup] !== null && app[lookup] !== '')
.map(lookup => appCacheKey({ lookup, value: app[lookup] }));
},
uidScanPattern: () => `${appNamespace()}:uid:*`,
pendingNamespace: () => 'pending_app',
pendingKey: ({ lookup, value }) => (
`${AppRedisCacheSpace.pendingNamespace()}:${lookup}:${value}`
),
openCountKey: uid => `apps:open_count:uid:${uid}`,
userCountKey: uid => `apps:user_count:uid:${uid}`,
referralCountKey: uid => `apps:referral_count:uid:${uid}`,
statsKeys: uid => [
AppRedisCacheSpace.openCountKey(uid),
AppRedisCacheSpace.userCountKey(uid),
AppRedisCacheSpace.referralCountKey(uid),
],
associationAppsKey: (fileExtension) => {
const ext = String(fileExtension ?? '')
.trim()
.replace(/^\./, '')
.toLowerCase();
return `assocs:${ext}:apps`;
},
getCachedApp: async ({ lookup, value }) => (
safeParseJson(await redisClient.get(appCacheKey({ lookup, value })))
),
setCachedApp: async (app, { ttlSeconds } = {}) => {
if ( ! app ) return;
const serialized = JSON.stringify(app);
const writes = AppRedisCacheSpace.keysForApp(app)
.map(key => setKey(key, serialized, { ttlSeconds }));
if ( writes.length ) {
await Promise.all(writes);
}
},
invalidateCachedApp: (app, { includeStats = false } = {}) => {
if ( ! app ) return;
const keys = [...AppRedisCacheSpace.keysForApp(app)];
if ( includeStats && app.uid ) {
keys.push(...AppRedisCacheSpace.statsKeys(app.uid));
}
if ( keys.length ) {
return deleteRedisKeys(keys);
}
},
invalidateCachedAppName: async (name) => {
if ( ! name ) return;
const keys = [appCacheKey({
lookup: 'name',
value: name,
})];
return deleteRedisKeys(keys);
},
invalidateAppStats: async (uid) => {
if ( ! uid ) return;
return deleteRedisKeys(AppRedisCacheSpace.statsKeys(uid));
},
};
================================================
FILE: src/backend/src/modules/apps/AppsModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
class AppsModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const { AppInformationService } = require('./AppInformationService');
services.registerService('app-information', AppInformationService);
const { AppIconService } = require('./AppIconService');
services.registerService('app-icon', AppIconService);
const { OldAppNameService } = require('./OldAppNameService');
services.registerService('old-app-name', OldAppNameService);
const { ProtectedAppService } = require('./ProtectedAppService');
services.registerService('__protected-app', ProtectedAppService);
const RecommendedAppsService = require('./RecommendedAppsService').default;
services.registerService('recommended-apps', RecommendedAppsService);
const { AppPermissionService } = require('./AppPermissionService');
services.registerService('app-permission', AppPermissionService);
}
}
module.exports = {
AppsModule,
};
================================================
FILE: src/backend/src/modules/apps/OldAppNameService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../services/BaseService');
const { DB_READ } = require('../../services/database/consts');
const N_MONTHS = 4;
class OldAppNameService extends BaseService {
static LOG_DEBUG = true;
_init () {
this.db = this.services.get('database').get(DB_READ, 'old-app-name');
}
async '__on_boot.consolidation' () {
const svc_event = this.services.get('event');
svc_event.on('app.rename', async (_, { app_uid, old_name }) => {
this.log.info('GOT EVENT', { app_uid, old_name });
await this.db.write('INSERT INTO `old_app_names` (`app_uid`, `name`) VALUES (?, ?)',
[app_uid, old_name]);
});
}
async check_app_name (name) {
const rows = await this.db.read('SELECT * FROM `old_app_names` WHERE `name` = ?',
[name]);
if ( rows.length === 0 ) return;
// Check if the app has been renamed in the last N months
const [row] = rows;
const timestamp = row.timestamp instanceof Date ? row.timestamp : new Date(
// Ensure timestamp ir processed as UTC
row.timestamp.endsWith('Z') ? row.timestamp : `${row.timestamp }Z`);
const age = Date.now() - timestamp.getTime();
// const n_ms = 60 * 1000;
const n_ms = N_MONTHS * 30 * 24 * 60 * 60 * 1000;
this.log.info('AGE INFO', {
input_time: row.timestamp,
age,
n_ms,
});
if ( age > n_ms ) {
// Remove record
await this.db.write('DELETE FROM `old_app_names` WHERE `id` = ?',
[row.id]);
// Return undefined
return;
}
return {
id: row.id,
app_uid: row.app_uid,
};
}
async remove_name (id) {
await this.db.write('DELETE FROM `old_app_names` WHERE `id` = ?',
[id]);
}
}
module.exports = {
OldAppNameService,
};
================================================
FILE: src/backend/src/modules/apps/ProtectedAppService.js
================================================
/*
* 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 .
*/
const { get_app } = require('../../helpers');
const { UserActorType } = require('../../services/auth/Actor');
const { PermissionImplicator, PermissionUtil, PermissionRewriter } =
require('../../services/auth/permissionUtils.mjs');
const BaseService = require('../../services/BaseService');
/**
* @class ProtectedAppService
* @extends BaseService
* @classdesc This class represents a service that handles protected applications. It extends the BaseService and includes
* methods for initializing permissions and registering rewriters and implicators for permission handling. The class
* ensures that the owner of a protected app has implicit permission to access it.
*/
class ProtectedAppService extends BaseService {
/**
* Initializes the ProtectedAppService.
* Registers a permission rewriter and implicator to handle application-specific permissions.
* @async
* @method _init
* @memberof ProtectedAppService
* @returns {Promise} A promise that resolves when the initialization is complete.
*/
async _init () {
const svc_permission = this.services.get('permission');
svc_permission.register_rewriter(PermissionRewriter.create({
matcher: permission => {
if ( ! permission.startsWith('app:') ) return false;
const [_, specifier] = PermissionUtil.split(permission);
if ( specifier.startsWith('uid#') ) return false;
return true;
},
rewriter: async permission => {
const [_1, name, ...rest] = PermissionUtil.split(permission);
const app = await get_app({ name });
return PermissionUtil.join(_1, `uid#${app.uid}`, ...rest);
},
}));
// track: object description in comment
// Owner of procted app has implicit permission to access it
svc_permission.register_implicator(PermissionImplicator.create({
matcher: permission => {
return permission.startsWith('app:') || permission.startsWith('manage:app');
},
checker: async ({ actor, permission }) => {
if ( ! (actor.type instanceof UserActorType) ) {
return undefined;
}
const parts = PermissionUtil.split(permission);
if ( parts[0] === 'manage' ) parts.shift();
if ( parts.length < 2 ) return undefined;
const [_, uid_part] = parts;
// track: slice a prefix
const uid = uid_part.slice('uid#'.length);
const app = await get_app({ uid });
if ( app.owner_user_id !== actor.type.user.id ) {
return undefined;
}
return {};
},
}));
}
}
module.exports = {
ProtectedAppService,
};
================================================
FILE: src/backend/src/modules/apps/RecommendedAppsRedisCacheSpace.js
================================================
/*
* 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 .
*/
export const RecommendedAppsRedisCacheSpace = {
key: ({ iconSize } = {}) => `global:recommended-apps${iconSize ? `:icon-size:${iconSize}` : ''}`,
};
================================================
FILE: src/backend/src/modules/apps/RecommendedAppsService.js
================================================
/*
* 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 .
*/
import { redisClient } from '../../clients/redis/redisSingleton.js';
import { deleteRedisKeys } from '../../clients/redis/deleteRedisKeys.js';
import { setRedisCacheValue } from '../../clients/redis/cacheUpdate.js';
import { get_apps } from '../../helpers.js';
import BaseService from '../../services/BaseService.js';
import { RecommendedAppsRedisCacheSpace } from './RecommendedAppsRedisCacheSpace.js';
export default class RecommendedAppsService extends BaseService {
static APP_NAMES = [
'app-center',
'dev-center',
'editor',
'code',
'camera',
'recorder',
'shell-shockers-outpan',
'krunker',
'slash-frvr',
'judge0',
'viewer',
'solitaire-frvr',
'tiles-beat',
'silex',
'markus',
'puterjs-playground',
'player',
'grist',
'pdf',
'photopea',
'polotno',
'basketball-frvr',
'gold-digger-frvr',
'plushie-connect',
'hex-frvr',
'spider-solitaire',
'danger-cross',
'doodle-jump-extra',
'endless-lake',
'sword-and-jewel',
'reversi-2',
'in-orbit',
'bowling-king',
'calc-hklocykcpts',
'virtu-piano',
'battleship-war',
'turbo-racing',
'guns-and-bottles',
'tronix',
'jewel-classic',
];
_construct () {
this.app_names = new Set(RecommendedAppsService.APP_NAMES);
}
'__on_boot.consolidation' () {
const svc_appIcon = this.services.get('app-icon');
const svc_event = this.services.get('event');
svc_event.on('apps.invalidate', async (_, { app }) => {
const sizes = svc_appIcon.getSizes();
// If it's a single-app invalidation, only invalidate if the
// app is in the list of recommended apps
if ( app ) {
const name = app.name;
if ( ! this.app_names.has(name) ) return;
}
const keys = [RecommendedAppsRedisCacheSpace.key()];
for ( const size of sizes ) {
const key = RecommendedAppsRedisCacheSpace.key({ iconSize: size });
keys.push(key);
}
await deleteRedisKeys(keys);
});
}
async get_recommended_apps ({ icon_size: iconSize }) {
const recommendedCacheKey = RecommendedAppsRedisCacheSpace.key({ iconSize });
const cachedRecommended = await redisClient.get(recommendedCacheKey);
if ( cachedRecommended ) {
try {
return JSON.parse(cachedRecommended);
} catch (e) {
// no op cache is in an invalid state
}
}
// Prepare each app for returning to user by only returning the necessary fields
// and adding them to the retobj array
let recommended = (await get_apps(Array.from(this.app_names).map(name => ({ name })))).filter(app => !!app).map(app => {
return {
uuid: app.uid,
name: app.name,
title: app.title,
icon: app.icon,
godmode: app.godmode,
maximize_on_start: app.maximize_on_start,
index_url: app.index_url,
};
});
const svc_appIcon = this.services.get('app-icon');
// Iconify apps
if ( iconSize ) {
recommended = await svc_appIcon.iconifyApps({
apps: recommended,
size: iconSize,
});
}
await setRedisCacheValue(recommendedCacheKey, JSON.stringify(recommended), {
eventData: recommended,
});
return recommended;
}
}
================================================
FILE: src/backend/src/modules/apps/default-app-icon.js
================================================
/*
* 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 .
*/
module.exports = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgdmVyc2lvbj0iMS4xIgogICB3aWR0aD0iNDgiCiAgIGhlaWdodD0iNDgiCiAgIGlkPSJzdmc2NjQ5IgogICB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiCiAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiAgPGRlZnMKICAgICBpZD0iZGVmczY2NTEiPgogICAgPGxpbmVhckdyYWRpZW50CiAgICAgICB4bGluazpocmVmPSIjbGluZWFyR3JhZGllbnQxMjEzMDMiCiAgICAgICBpZD0ibGluZWFyR3JhZGllbnQxMjE3NjQiCiAgICAgICBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIKICAgICAgIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMS4wMDU5MTg0LDAsMCwwLjg1NzEwOTk5LC0wLjEyNzgyMjg3LDguMTA2NDc1MSkiCiAgICAgICB4MT0iMjUuMDg2MDM5IgogICAgICAgeTE9Ii0xLjM2MjM2OTEiCiAgICAgICB4Mj0iMjUuMDg2MDM5IgogICAgICAgeTI9IjE4LjI5OTMzNCIgLz4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgaWQ9ImxpbmVhckdyYWRpZW50MTIxMzAzIj4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AxMjEyOTUiCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjEiCiAgICAgICAgIG9mZnNldD0iMCIgLz4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AxMjEyOTciCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjAuMjM1Mjk0MTIiCiAgICAgICAgIG9mZnNldD0iMC4xMTQxOTQ2OCIgLz4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AxMjEyOTkiCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjAuMTU2ODYyNzUiCiAgICAgICAgIG9mZnNldD0iMC45Mzg5NjU5OCIgLz4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AxMjEzMDEiCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjAuMzkyMTU2ODciCiAgICAgICAgIG9mZnNldD0iMSIgLz4KICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgICA8bGluZWFyR3JhZGllbnQKICAgICAgIHhsaW5rOmhyZWY9IiNsaW5lYXJHcmFkaWVudDM5MjQtMi0yLTUtOCIKICAgICAgIGlkPSJsaW5lYXJHcmFkaWVudDEyMTc2MCIKICAgICAgIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIgogICAgICAgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxLjAwMDAwMDMsMCwwLDAuODM3ODM4MTMsLTEuMjQ4MTQ2ZS01LDcuODkxODg1MykiCiAgICAgICB4MT0iMjMuOTk5OTkiCiAgICAgICB5MT0iNi4wNDQ1Mjc1IgogICAgICAgeDI9IjIzLjk5OTk5IgogICAgICAgeTI9IjQxLjc2MzIyMiIgLz4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgaWQ9ImxpbmVhckdyYWRpZW50MzkyNC0yLTItNS04Ij4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AzOTI2LTktNC05LTYiCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjEiCiAgICAgICAgIG9mZnNldD0iMCIgLz4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AzOTI4LTktOC02LTUiCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjAuMjM1Mjk0MTIiCiAgICAgICAgIG9mZnNldD0iMC4wOTMwMjMyNSIgLz4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AzOTMwLTMtNS0xLTciCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjAuMTU2ODYyNzUiCiAgICAgICAgIG9mZnNldD0iMC45MDY5NzY3IiAvPgogICAgICA8c3RvcAogICAgICAgICBpZD0ic3RvcDM5MzItOC0wLTQtOCIKICAgICAgICAgc3R5bGU9InN0b3AtY29sb3I6I2ZmZmZmZjtzdG9wLW9wYWNpdHk6MC4zOTIxNTY4NyIKICAgICAgICAgb2Zmc2V0PSIxIiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgeGxpbms6aHJlZj0iI2QiCiAgICAgICBpZD0ibGluZWFyR3JhZGllbnQxMjE3NTgiCiAgICAgICBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIKICAgICAgIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMS4yMTIyOTAzLDAsMCwxLjExNDU1MTQsLTQuNDk5OTAzLC0yLjc2MTI1MzMpIgogICAgICAgeDE9IjIzLjQ1MiIKICAgICAgIHkxPSIzMC41NTUiCiAgICAgICB4Mj0iNDMuMDA3IgogICAgICAgeTI9IjQ1LjkzMzk5OCIgLz4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgaWQ9ImQiPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjAiCiAgICAgICAgIHN0b3AtY29sb3I9IiNmZmYiCiAgICAgICAgIHN0b3Atb3BhY2l0eT0iMCIKICAgICAgICAgaWQ9InN0b3A2NSIgLz4KICAgICAgPHN0b3AKICAgICAgICAgb2Zmc2V0PSIxIgogICAgICAgICBzdG9wLWNvbG9yPSIjZmZmIgogICAgICAgICBzdG9wLW9wYWNpdHk9IjAiCiAgICAgICAgIGlkPSJzdG9wNjciIC8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPGxpbmVhckdyYWRpZW50CiAgICAgICB4bGluazpocmVmPSIjbGluZWFyR3JhZGllbnQxMDYzMDUiCiAgICAgICBpZD0ibGluZWFyR3JhZGllbnQxMjE3NTYiCiAgICAgICBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIKICAgICAgIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMS4yMTk2MzY1LDAsMCwxLjMyMDM3MDgsNDAuNzg1OTE1LC0xMy4zMzg3NDQpIgogICAgICAgeDE9Ii01Ljg4NzAzMzUiCiAgICAgICB5MT0iMTkuMzQxOTE1IgogICAgICAgeDI9Ii01Ljg4NzAzMzUiCiAgICAgICB5Mj0iNDMuMzc1NzQ4IiAvPgogICAgPGxpbmVhckdyYWRpZW50CiAgICAgICBpZD0ibGluZWFyR3JhZGllbnQxMDYzMDUiPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjAiCiAgICAgICAgIHN0b3AtY29sb3I9IiNkYWMxOTciCiAgICAgICAgIGlkPSJzdG9wMTA2MzAxIgogICAgICAgICBzdHlsZT0ic3RvcC1jb2xvcjojZTdjNTkxO3N0b3Atb3BhY2l0eToxIiAvPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjEiCiAgICAgICAgIHN0b3AtY29sb3I9IiNiMTk5NzQiCiAgICAgICAgIGlkPSJzdG9wMTA2MzAzIgogICAgICAgICBzdHlsZT0ic3RvcC1jb2xvcjojY2ZhMjVlO3N0b3Atb3BhY2l0eToxIiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgeGxpbms6aHJlZj0iI2xpbmVhckdyYWRpZW50MTA2MzA1IgogICAgICAgaWQ9ImxpbmVhckdyYWRpZW50MTcwMyIKICAgICAgIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIgogICAgICAgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxLjIxOTYzNjUsMCwwLDEuMzE1NDE2NSw0MC44MDAzMzgsLTEyLjk4MzQyMikiCiAgICAgICB4MT0iLTUuODg3MDMzNSIKICAgICAgIHkxPSIxMS40ODI5NzgiCiAgICAgICB4Mj0iLTUuODg3MDMzNSIKICAgICAgIHkyPSIyMi4xNDg4NjUiIC8+CiAgICA8cmFkaWFsR3JhZGllbnQKICAgICAgIGN4PSI1IgogICAgICAgY3k9IjQxLjUiCiAgICAgICBmeD0iNSIKICAgICAgIGZ5PSI0MS41IgogICAgICAgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxLjAwMjg4NzEsMCwwLDEuNiwtMTguMTY3MTM4LC0xMTEuOTgyODkpIgogICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiCiAgICAgICB4bGluazpocmVmPSIjZyIKICAgICAgIGlkPSJrLTAtNy0zLTktMyIKICAgICAgIHI9IjUiIC8+CiAgICA8bGluZWFyR3JhZGllbnQKICAgICAgIGlkPSJnIj4KICAgICAgPHN0b3AKICAgICAgICAgb2Zmc2V0PSIwIgogICAgICAgICBpZD0ic3RvcDEzIiAvPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjEiCiAgICAgICAgIHN0b3Atb3BhY2l0eT0iMCIKICAgICAgICAgaWQ9InN0b3AxNSIgLz4KICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgICA8bGluZWFyR3JhZGllbnQKICAgICAgIHhsaW5rOmhyZWY9IiNoIgogICAgICAgaWQ9ImxpbmVhckdyYWRpZW50MTIxNzU0IgogICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiCiAgICAgICBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDIuMTMwNDMzMiwwLDAsMS40NTQ1NSwtODcuNzE5MDE4LC0xMy4zMjcxMSkiCiAgICAgICB4MT0iMTcuNTU0MDAxIgogICAgICAgeTE9IjQ2IgogICAgICAgeDI9IjE3LjU1NDAwMSIKICAgICAgIHkyPSIzNSIgLz4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgaWQ9ImgiPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjAiCiAgICAgICAgIHN0b3Atb3BhY2l0eT0iMCIKICAgICAgICAgaWQ9InN0b3A1NCIgLz4KICAgICAgPHN0b3AKICAgICAgICAgb2Zmc2V0PSIuNSIKICAgICAgICAgaWQ9InN0b3A1NiIgLz4KICAgICAgPHN0b3AKICAgICAgICAgb2Zmc2V0PSIxIgogICAgICAgICBzdG9wLW9wYWNpdHk9IjAiCiAgICAgICAgIGlkPSJzdG9wNTgiIC8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPHJhZGlhbEdyYWRpZW50CiAgICAgICBjeD0iNSIKICAgICAgIGN5PSI0MS41IgogICAgICAgZng9IjUiCiAgICAgICBmeT0iNDEuNSIKICAgICAgIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMS4wMDI4ODcxLDAsMCwxLjYsNTcuMTM5MDQ4LC0xMTEuOTgyODkpIgogICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiCiAgICAgICB4bGluazpocmVmPSIjZyIKICAgICAgIGlkPSJpLTYtOS03LTgtOSIKICAgICAgIHI9IjUiIC8+CiAgICA8bGluZWFyR3JhZGllbnQKICAgICAgIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIgogICAgICAgeGxpbms6aHJlZj0iI2MtMyIKICAgICAgIGlkPSJuIgogICAgICAgeDE9IjI2IgogICAgICAgeDI9IjI2IgogICAgICAgeTE9IjIyIgogICAgICAgeTI9IjgiCiAgICAgICBncmFkaWVudFRyYW5zZm9ybT0idHJhbnNsYXRlKDAsLTMpIiAvPgogICAgPGxpbmVhckdyYWRpZW50CiAgICAgICBpZD0iYy0zIj4KICAgICAgPHN0b3AKICAgICAgICAgb2Zmc2V0PSIwIgogICAgICAgICBzdG9wLWNvbG9yPSIjZmZmIgogICAgICAgICBpZD0ic3RvcDM2LTYiIC8+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC40MjgxODMwNSIKICAgICAgICAgc3RvcC1jb2xvcj0iI2ZmZiIKICAgICAgICAgaWQ9InN0b3AzOC03IiAvPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjAuNTAwOTMzMTciCiAgICAgICAgIHN0b3AtY29sb3I9IiNmZmYiCiAgICAgICAgIHN0b3Atb3BhY2l0eT0iLjY0MyIKICAgICAgICAgaWQ9InN0b3A0MC01IiAvPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjEiCiAgICAgICAgIHN0b3AtY29sb3I9IiNmZmYiCiAgICAgICAgIHN0b3Atb3BhY2l0eT0iLjM5MSIKICAgICAgICAgaWQ9InN0b3A0Mi0zIiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgPG1ldGFkYXRhCiAgICAgaWQ9Im1ldGFkYXRhNjY1NCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGcKICAgICBpZD0iZzEyMTAiCiAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMC43MTE4NjQzOCwwLDAsMC43NSw1MC44MDQ1NjIsNi44MTI4MzI4KSIKICAgICBzdHlsZT0ic3Ryb2tlLXdpZHRoOjEuMzY4NTgiPgogICAgPHJlY3QKICAgICAgIGZpbGw9InVybCgjaSkiCiAgICAgICBoZWlnaHQ9IjE2IgogICAgICAgb3BhY2l0eT0iMC40IgogICAgICAgdHJhbnNmb3JtPSJzY2FsZSgtMSkiCiAgICAgICB3aWR0aD0iNSIKICAgICAgIHg9IjYyLjE1NDAzIgogICAgICAgeT0iLTUzLjU4Mjg5IgogICAgICAgaWQ9InJlY3Q3Ny05LTkwLTItNy04IgogICAgICAgc3R5bGU9ImZpbGw6dXJsKCNpLTYtOS03LTgtOSk7c3Ryb2tlLXdpZHRoOjEuMzY4NTgiIC8+CiAgICA8cmVjdAogICAgICAgZmlsbD0idXJsKCNqKSIKICAgICAgIGhlaWdodD0iMTYiCiAgICAgICBvcGFjaXR5PSIwLjQiCiAgICAgICB3aWR0aD0iNDkiCiAgICAgICB4PSItNjIuMTU0MDMiCiAgICAgICB5PSIzNy41ODI4OSIKICAgICAgIGlkPSJyZWN0NzktNy0yLTAtMS00IgogICAgICAgc3R5bGU9ImZpbGw6dXJsKCNsaW5lYXJHcmFkaWVudDEyMTc1NCk7c3Ryb2tlLXdpZHRoOjEuMzY4NTgiIC8+CiAgICA8cmVjdAogICAgICAgZmlsbD0idXJsKCNrKSIKICAgICAgIGhlaWdodD0iMTYiCiAgICAgICBvcGFjaXR5PSIwLjQiCiAgICAgICB0cmFuc2Zvcm09InNjYWxlKDEsLTEpIgogICAgICAgd2lkdGg9IjUiCiAgICAgICB4PSItMTMuMTU0MDI4IgogICAgICAgeT0iLTUzLjU4Mjg5IgogICAgICAgaWQ9InJlY3Q4MS0zLTgtNi03LTgiCiAgICAgICBzdHlsZT0iZmlsbDp1cmwoI2stMC03LTMtOS0zKTtzdHJva2Utd2lkdGg6MS4zNjg1OCIgLz4KICA8L2c+CiAgPHBhdGgKICAgICBpZD0icmVjdDU1MDUtMjEtMS01LTAtNi01LTEtMi01LTEwIgogICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZvbnQtdmFyaWF0aW9uLXNldHRpbmdzOm5vcm1hbDtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO3Zpc2liaWxpdHk6dmlzaWJsZTt2ZWN0b3ItZWZmZWN0Om5vbmU7ZmlsbDp1cmwoI2xpbmVhckdyYWRpZW50MTcwMyk7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmU7c3Ryb2tlLXdpZHRoOjAuOTk5OTk5O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7c3Ryb2tlLW9wYWNpdHk6MC4zOy1pbmtzY2FwZS1zdHJva2U6bm9uZTttYXJrZXI6bm9uZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMCIKICAgICBkPSJNIDExLjU5MDkyMyw1LjUgQyA5LjIzMzkwNSw1LjUgOC4yOTM2NSw2Ljg5NjUxODMgNy4zMzYzNzgsOS4wNTgwMjUyIDYuNjAyNjI1LDEwLjcxMDQ1NyA1Ljc0ODksMTIuNDIwMTYyIDUuMDcwNjEzLDE0LjAzOTI2IDQuNzA5ODY5LDE0LjY2Njk5NCA0LjUwMDAxNCwxNS4zOTQ1MDYgNC41MDAwMTQsMTYuMTc0MDc1IGggMzkuMDAwMDAzIGMgMCwtMC43Nzk1NjkgLTAuMjA5ODU1LC0xLjUwNzA4MSAtMC41NzA1OTgsLTIuMTM0ODE1IEMgNDIuMjMyNzQ0LDEyLjQyODM2MSA0MS40MTc5MiwxMC43MDExOTIgNDAuNjYzNjUzLDkuMDU4MDI1MiAzOS42NzczNzksNi45MDk2ODc3IDM4Ljc2NjEyNiw1LjUgMzYuNDA5MTA4LDUuNSBaIiAvPgogIDxwYXRoCiAgICAgaWQ9InJlY3Q1NTA1LTIxLTEtNS0wLTYtNS0xLTItMyIKICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmb250LXZhcmlhdGlvbi1zZXR0aW5nczpub3JtYWw7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTt2aXNpYmlsaXR5OnZpc2libGU7dmVjdG9yLWVmZmVjdDpub25lO2ZpbGw6dXJsKCNsaW5lYXJHcmFkaWVudDEyMTc1Nik7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmU7c3Ryb2tlLXdpZHRoOjAuOTk5OTk5O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7c3Ryb2tlLW9wYWNpdHk6MC4zOy1pbmtzY2FwZS1zdHJva2U6bm9uZTttYXJrZXI6bm9uZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMCIKICAgICBkPSJNIDguNzU0NTQ1LDEyIEMgNi45ODE4MTgsMTIgNC41LDEzLjU1NjQ1NyA0LjUsMTcuMzU3MTM5IHYgMjIuODU3MTI2IGMgMCwwLjE4MDAwMiAwLjAxNDU0LDAuMzU2MjQ0IDAuMDM2MDIsMC41MzAxMzQgMC4wMDUsMC4wNDAzMiAwLjAxMTk4LDAuMDgwMDEgMC4wMTgwMSwwLjExOTk3NiAwLjAyMTQyLDAuMTQwNDQzIDAuMDQ4NSwwLjI3ODg0MyAwLjA4MzEsMC40MTQzNDIgMC4wMDg5LDAuMDM0OTcgMC4wMTY2NywwLjA3MDAyIDAuMDI2MzEsMC4xMDQ2MzEgMC4wOTcxMywwLjM0MzgzNyAwLjIzMzc3MywwLjY3MDg5OCAwLjQwNzE3NCwwLjk3Mzc3MiA1LjFlLTQsOS4yOWUtNCA3LjA5ZS00LDAuMDAxOCAwLjAwMTQsMC4wMDI4IDAuNzM0MTUsMS4yODAyNTkgMi4xMDM0MTksMi4xNDAwNyAzLjY4MjUxNSwyLjE0MDA3IGggMzAuNDkwOTEyIGMgMS41NzkwOTYsMCAyLjk0ODM2NSwtMC44NTk4MTEgMy42ODI1NjUsLTIuMTQwMDY2IDMuOTZlLTQsLTkuMjllLTQgNy4wOWUtNCwtMC4wMDE5IDAuMDAxNCwtMC4wMDI4IDAuMTczNDAxLC0wLjMwMjg3NCAwLjMxMDA1LC0wLjYyOTkzNSAwLjQwNzE3NSwtMC45NzM3NzIgMC4wMDk2LC0wLjAzNDYxIDAuMDE3NTIsLTAuMDY5NjYgMC4wMjYzMSwtMC4xMDQ2MzEgMC4wMzQ2LC0wLjEzNTQ5OSAwLjA2MTY5LC0wLjI3Mzg5OCAwLjA4MzEsLTAuNDE0MzQxIDAuMDA1NywtMC4wMzk5NyAwLjAxMzEyLC0wLjA3OTY1IDAuMDE4MDEsLTAuMTE5OTc3IDAuMDIxNDksLTAuMTczODk0IDAuMDM1OTYsLTAuMzUwMTM2IDAuMDM1OTYsLTAuNTMwMTM4IFYgMTcuNzE0MjgyIGMgMCwtMi42NzU0NzUgLTEuMDYzNjM3LC01LjcxNDI4MSAtNC4yNTQ1NDYsLTUuNzE0MjgxIHoiIC8+CiAgPHBhdGgKICAgICBkPSJtIDEwLjY0NDg2MSwxMS4yOTY1MDUgaCAyNi4xNDQxODUgYyAxLjUyNjY3MywwIDIuNDcxMTgyLDAuNTI4MDExIDMuMTEwNzgyLDEuOTc5Njg1IGwgMi4yMDE3MjcsNi4wOTEzMzkgdiAyMS45NTk0MiBjIDAsMS4zODU0OTUgLTAuNzc0MzI3LDIuMDgzNTggLTIuMzAwMjkxLDIuMDgzNTggSCA3LjkwNzc3IGMgLTEuNTI1OTY0LDAgLTIuMTQ4NTQ2LC0wLjc2NzgyMiAtMi4xNDg1NDYsLTIuMTUzMzE3IFYgMTkuMzY2MTA1IGwgMi4xMzA4MTksLTYuMjIxNTYyIGMgMC40MjU0NTUsLTEuMTI0MzM2IDEuMjI4ODU1LC0xLjg0ODc1IDIuNzU0ODE4LC0xLjg0ODc1IHoiCiAgICAgZGlzcGxheT0iYmxvY2siCiAgICAgZmlsbD0ibm9uZSIKICAgICBvcGFjaXR5PSIwLjUwNSIKICAgICBvdmVyZmxvdz0idmlzaWJsZSIKICAgICBzdHJva2U9InVybCgjbSkiCiAgICAgc3Ryb2tlLXdpZHRoPSIwLjc0MTk5OCIKICAgICBzdHlsZT0ic3Ryb2tlOnVybCgjbGluZWFyR3JhZGllbnQxMjE3NTgpO21hcmtlcjpub25lIgogICAgIGlkPSJwYXRoODUtMS04LTUtNy0wIiAvPgogIDxyZWN0CiAgICAgc3R5bGU9Im9wYWNpdHk6MC4zO2ZpbGw6bm9uZTtzdHJva2U6dXJsKCNsaW5lYXJHcmFkaWVudDEyMTc2MCk7c3Ryb2tlLXdpZHRoOjAuOTk5OTg0O3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO3N0cm9rZS1vcGFjaXR5OjEiCiAgICAgaWQ9InJlY3Q2NzQxLTUtMC0yLTMtNC0yLTQiCiAgICAgeT0iMTIuNDk5OTkyIgogICAgIHg9IjUuNDk5OTk0MyIKICAgICByeT0iMy41IgogICAgIGhlaWdodD0iMzEuMDAwMDE3IgogICAgIHdpZHRoPSIzNyIKICAgICByeD0iMy41IiAvPgogIDxwYXRoCiAgICAgaWQ9InJlY3Q1NTA1LTIxLTEtNS0wLTYtNS0xLTItNS0xLTQiCiAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7Zm9udC12YXJpYXRpb24tc2V0dGluZ3M6bm9ybWFsO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7dmlzaWJpbGl0eTp2aXNpYmxlO3ZlY3Rvci1lZmZlY3Q6bm9uZTtmaWxsOm5vbmU7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOiM4MDRiMDA7c3Ryb2tlLXdpZHRoOjAuOTk5OTk5O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7c3Ryb2tlLW9wYWNpdHk6MC41Oy1pbmtzY2FwZS1zdHJva2U6bm9uZTttYXJrZXI6bm9uZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMCIKICAgICBkPSJtIDExLjU5MDkyMyw1LjQ5OTk5OTUgYyAtMi4zNTcwMTgsMCAtMy4yOTcyNzMsMS4zOTE1ODQ0IC00LjI1NDU0NSwzLjU0NTQ1NDYgQyA2LjYwMjYyNSwxMC42OTIwNDggNS43NDg5LDEyLjM5NTcxMyA1LjA3MDYxMywxNC4wMDkwOTEgNC43MDk4NjksMTQuNjM0NjA3IDQuNTAwMDE0LDE1LjM1OTU0OSA0LjUwMDAxNCwxNi4xMzYzNjMgdiAyNC4xMDkwOTIgYyAwLDIuMzU3MDE4IDEuODk3NTI3LDQuMjU0NTQ2IDQuMjU0NTQ1LDQuMjU0NTQ2IGggMzAuNDkwOTEzIGMgMi4zNTcwMTgsMCA0LjI1NDU0NSwtMS44OTc1MjggNC4yNTQ1NDUsLTQuMjU0NTQ2IFYgMTYuMTM2MzYzIGMgMCwtMC43NzY4MTQgLTAuMjA5ODU1LC0xLjUwMTc1NiAtMC41NzA1OTgsLTIuMTI3MjcyIEMgNDIuMjMyNzQ0LDEyLjQwMzg4MyA0MS40MTc5MiwxMC42ODI4MTYgNDAuNjYzNjUzLDkuMDQ1NDU0MSAzOS42NzczNzksNi45MDQ3MDY4IDM4Ljc2NjEyNiw1LjQ5OTk5OTUgMzYuNDA5MTA4LDUuNDk5OTk5NSBaIiAvPgogIDxwYXRoCiAgICAgaWQ9InJlY3Q1NTA1LTIxLTEtNS0wLTYtNS0xLTItNS0xLTctNyIKICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmb250LXZhcmlhdGlvbi1zZXR0aW5nczpub3JtYWw7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTt2aXNpYmlsaXR5OnZpc2libGU7b3BhY2l0eTowLjE1O3ZlY3Rvci1lZmZlY3Q6bm9uZTtmaWxsOm5vbmU7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOnVybCgjbGluZWFyR3JhZGllbnQxMjE3NjQpO3N0cm9rZS13aWR0aDowLjk5OTk5MTtzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDtzdHJva2Utb3BhY2l0eToxOy1pbmtzY2FwZS1zdHJva2U6bm9uZTttYXJrZXI6bm9uZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMCIKICAgICBkPSJNIDQxLjU1OTA5NywxMy4xOCAzOS44NDYyNjEsOS42MDExMDA3IEMgMzkuMzY4MTczLDguNTU5Njc2MSAzOC45MjI4MjksNy43NTkzNzQ5IDM4LjQwNDc1NSw3LjI2MTE2MyAzNy44ODY2NzQsNi43NjI5NTEyIDM3LjMxMzE3Miw2LjQ5OTk5NDUgMzYuMjg5NzksNi40OTk5OTQ1IEggMTEuNzExMjE4IGMgLTEuMDI0NzMsMCAtMS42MDg4MjEsMC4yNjI2MDMyIC0yLjEyODY4MDQsMC43NTg0MTU4IEMgOS4wNjI2ODA1LDcuNzU0MjIyOCA4LjYyMDYzMSw4LjU0ODc0MjMgOC4xNTg4NDg4LDkuNTkxNDY3NyB2IDAuMDAxNDEgTCA2LjU5Nzg2MDMsMTMuMjU2NzI1IiAvPgogIDxwYXRoCiAgICAgZD0ibSAyMiw1IGggNCBWIDE5IEMgMjUuNjA2LDE5IDI1LjIxMywxOC4yMjkgMjQuODE5LDE4LjIyOSAyNC40MTYsMTguMjI5IDI0LjAxMywxOSAyMy42MDksMTkgMjMuMjg1LDE5IDIyLjk2LDE4LjMyNSAyMi42MzYsMTguMzI1IDIyLjQyNCwxOC4zMjUgMjIuMjEyLDE5IDIyLDE5IFoiCiAgICAgZmlsbD0idXJsKCNuKSIKICAgICBvcGFjaXR5PSIwLjMiCiAgICAgb3ZlcmZsb3c9InZpc2libGUiCiAgICAgc3R5bGU9ImZpbGw6dXJsKCNuKTttYXJrZXI6bm9uZSIKICAgICBpZD0icGF0aDg3IiAvPgo8L3N2Zz4K';
================================================
FILE: src/backend/src/modules/apps/lib/IconResult.js
================================================
/*
* 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 .
*/
const { Context } = require('../../../util/context');
const { stream_to_buffer } = require('../../../util/streamutil');
module.exports = class IconResult {
constructor (o) {
Object.assign(this, o);
}
async get_data_url () {
if ( this.data_url ) {
return this.data_url;
} else {
try {
const buffer = await stream_to_buffer(this.stream);
return `data:${this.mime};base64,${buffer.toString('base64')}`;
} catch (e) {
const svc_error = Context.get(undefined, {
allow_fallback: true,
}).get('services').get('error');
svc_error.report('IconResult:get_data_url', {
source: e,
});
// TODO: broken image icon here
return `data:image/png;base64,${Buffer.from([]).toString('base64')}`;
}
}
}
};
================================================
FILE: src/backend/src/modules/apps/privateLaunchAccess.js
================================================
/*
* Copyright (C) 2026-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 .
*/
import { UserActorType } from '../../services/auth/Actor.js';
const DEFAULT_FALLBACK_APP_NAME = 'app-center';
function isPrivateApp (app) {
return Number(app?.is_private ?? 0) > 0;
}
function buildFallbackPath (appName) {
if ( typeof appName !== 'string' || !appName.trim() ) {
return '/app';
}
return `/app/${encodeURIComponent(appName.trim())}`;
}
function buildDefaultDeniedDecision (appName, reason) {
return {
hasAccess: false,
fallbackAppName: DEFAULT_FALLBACK_APP_NAME,
fallbackArgs: {
path: buildFallbackPath(appName),
},
reason: reason ?? 'private-access-required',
checkedBy: 'core/private-launch-access',
};
}
function normalizeLaunchDecision (decision, appName) {
if ( !decision || typeof decision !== 'object' ) {
return buildDefaultDeniedDecision(appName, 'invalid-private-access-result');
}
const hasAccess = !!decision.hasAccess;
if ( hasAccess ) {
return {
hasAccess: true,
reason: typeof decision.reason === 'string'
? decision.reason
: undefined,
checkedBy: typeof decision.checkedBy === 'string'
? decision.checkedBy
: undefined,
};
}
const fallbackAppName = typeof decision.fallbackAppName === 'string'
&& decision.fallbackAppName.trim()
? decision.fallbackAppName.trim()
: DEFAULT_FALLBACK_APP_NAME;
const fallbackPath = decision.fallbackArgs?.path;
const fallbackArgs = typeof fallbackPath === 'string' && fallbackPath.trim()
? { path: fallbackPath.trim() }
: { path: buildFallbackPath(appName) };
return {
hasAccess: false,
fallbackAppName,
fallbackArgs,
reason: typeof decision.reason === 'string'
? decision.reason
: undefined,
checkedBy: typeof decision.checkedBy === 'string'
? decision.checkedBy
: undefined,
};
}
function getActorUserUid (actor) {
if ( ! actor ) return null;
if ( actor.type instanceof UserActorType ) {
const userUid = actor.type?.user?.uuid;
return typeof userUid === 'string' && userUid ? userUid : null;
}
if ( typeof actor.get_related_actor === 'function' ) {
try {
const userActor = actor.get_related_actor(UserActorType);
const userUid = userActor?.type?.user?.uuid;
return typeof userUid === 'string' && userUid ? userUid : null;
} catch {
return null;
}
}
return null;
}
async function resolvePrivateLaunchAccess ({
app,
services,
userUid,
source,
args,
}) {
if ( ! isPrivateApp(app) ) {
return {
hasAccess: true,
checkedBy: 'core/public-app',
};
}
const deniedDecision = buildDefaultDeniedDecision(
app?.name,
'private-access-required',
);
const eventService = services?.get?.('event');
if ( ! eventService ) {
return {
...deniedDecision,
reason: 'private-access-event-service-unavailable',
};
}
const eventPayload = {
appUid: app?.uid,
appName: app?.name,
userUid: typeof userUid === 'string' && userUid ? userUid : null,
source: source ?? 'unknown',
args: args ?? {},
result: { ...deniedDecision },
};
try {
await eventService.emit('app.privateAccess.resolveLaunch', eventPayload);
} catch {
return {
...deniedDecision,
reason: 'private-access-check-error',
};
}
return normalizeLaunchDecision(eventPayload.result, app?.name);
}
export {
getActorUserUid,
isPrivateApp,
resolvePrivateLaunchAccess,
};
================================================
FILE: src/backend/src/modules/broadcast/BroadcastModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
class BroadcastModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const { BroadcastService } = require('./BroadcastService');
services.registerService('broadcast', BroadcastService);
}
}
module.exports = {
BroadcastModule,
};
================================================
FILE: src/backend/src/modules/broadcast/BroadcastService.js
================================================
/*
* 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 .
*/
import { createHmac, randomUUID, timingSafeEqual } from 'crypto';
import { Agent as HttpsAgent } from 'https';
import axios from 'axios';
import { redisClient } from '../../clients/redis/redisSingleton.js';
import { BaseService } from '../../services/BaseService.js';
import { Context } from '../../util/context.js';
import { Endpoint } from '../../util/expressutil.js';
export class BroadcastService extends BaseService {
#peersByKey = {};
#webhookPeers = [];
#incomingLastNonceByPeer = new Map();
#outgoingNonceByPeer = new Map();
#outboundEventsByDedupKey = new Map();
#outboundFlushTimer = null;
#outboundIsFlushing = false;
#dedupFallbackCounter = 0;
#webhookReplayWindowSeconds = 300;
#outboundFlushMs = 5000;
#webhookHostHeader = null;
#webhookProtocol = 'https';
#webhookHttpsAgent = new HttpsAgent({ rejectUnauthorized: false });
#redisPubSubChannel = 'broadcast.webhook.events';
#redisSubscriber = null;
#redisSourceId = randomUUID();
async _init () {
const peers = this.config.peers ?? [];
const replayWindowSeconds = this.config.webhook_replay_window_seconds ?? 300;
const outboundFlushMs = Number(this.config.outbound_flush_ms ?? 2000);
for ( const peer_config of peers ) {
const peerId = this.#resolvePeerId(peer_config);
if ( ! peerId ) {
console.warn('ignoring broadcast peer config with missing key/peerId', { peer_config });
continue;
}
if ( this.#peersByKey[peerId] ) {
console.warn('duplicate broadcast peer id configured', {
peerId,
existing: this.#peersByKey[peerId]?.webhook_url,
duplicate: peer_config.webhook_url,
});
}
this.#peersByKey[peerId] = {
webhook_secret: peer_config.webhook_secret,
webhook_url: peer_config.webhook_url,
webhook: !!peer_config.webhook,
};
if ( peer_config.webhook ) {
this.#webhookPeers.push({
...peer_config,
peerId,
});
} else {
console.warn('ignoring non-webhook broadcast peer; websocket transport is disabled', {
peerId,
});
}
}
this.#webhookReplayWindowSeconds = replayWindowSeconds;
this.#outboundFlushMs = Number.isFinite(outboundFlushMs) && outboundFlushMs >= 0
? outboundFlushMs
: 5000;
this.#webhookHostHeader = this.global_config.domain;
{
const protocol = String(this.global_config.protocol ?? '').trim().replace(/:$/, '').toLowerCase();
this.#webhookProtocol = protocol === 'http' || protocol === 'https' ? protocol : 'https';
}
this.#redisSourceId = `${String(this.global_config?.server_id ?? 'local')}:${randomUUID()}`;
await this.#initRedisPubSub();
const svc_event = this.services.get('event');
svc_event.on('outer.*', this.outBroadcastEventHandler.bind(this));
}
async outBroadcastEventHandler (key, data, meta) {
if ( meta?.from_outside ) return;
const safeMeta = this.#normalizeMeta(meta);
const outboundEvent = { key, data, meta: safeMeta };
// Mirror local outer.pub events to Redis so same-cluster replicas
// receive them even when this instance is the originator.
this.#publishWebhookEventsToRedis([outboundEvent]).catch(error => {
console.warn('local redis pubsub publish failed', { error, key });
});
this.#enqueueOutboundEvent(outboundEvent);
}
#enqueueOutboundEvent (event) {
const dedupKey = this.#createDedupKey(event);
this.#outboundEventsByDedupKey.set(dedupKey, event);
this.#scheduleOutboundFlush();
}
#createDedupKey (event) {
try {
return JSON.stringify(event);
} catch {
const fallbackKey = `fallback-${this.#dedupFallbackCounter}`;
this.#dedupFallbackCounter += 1;
return fallbackKey;
}
}
#scheduleOutboundFlush () {
if ( this.#outboundFlushTimer ) return;
this.#outboundFlushTimer = setTimeout(async () => {
this.#outboundFlushTimer = null;
try {
await this.#flushOutboundEvents();
} catch ( error ) {
console.warn('outbound broadcast flush failed', { error });
}
}, this.#outboundFlushMs);
}
async #flushOutboundEvents () {
if ( this.#outboundIsFlushing || this.#outboundEventsByDedupKey.size === 0 ) return;
this.#outboundIsFlushing = true;
try {
const events = [...this.#outboundEventsByDedupKey.values()];
this.#outboundEventsByDedupKey.clear();
for ( const peer_config of this.#webhookPeers ) {
try {
await this.#sendWebhookToPeer(peer_config, events);
} catch (e) {
console.warn(`webhook broadcast send error: ${ JSON.stringify({ peer: peer_config.peerId ?? peer_config.key, error: e.message })}`);
}
}
} finally {
this.#outboundIsFlushing = false;
if ( this.#outboundEventsByDedupKey.size > 0 ) {
this.#scheduleOutboundFlush();
}
}
}
#normalizeMeta (meta) {
if ( !meta || typeof meta !== 'object' || Array.isArray(meta) ) {
return {};
}
return meta;
}
#resolveLocalPeerId () {
const localPeerId = this.config?.webhook?.peerId ?? this.config?.webhook?.key;
if ( typeof localPeerId !== 'string' || localPeerId.trim() === '' ) return null;
return localPeerId.trim();
}
#resolvePeerId (peerConfig) {
if ( !peerConfig || typeof peerConfig !== 'object' ) return null;
const peerId = peerConfig.peerId ?? peerConfig.key;
if ( typeof peerId !== 'string' || peerId.trim() === '' ) return null;
return peerId.trim();
}
#isNonceReplayForPeer ({ timestamp, nonce, peerId }) {
const lastSeen = this.#incomingLastNonceByPeer.get(peerId);
if ( ! lastSeen ) return false;
// A newer timestamp should reset nonce ordering for this peer.
if ( timestamp > lastSeen.timestamp ) return false;
if ( timestamp < lastSeen.timestamp ) return true;
return nonce <= lastSeen.nonce;
}
async #initRedisPubSub () {
if ( typeof redisClient?.duplicate !== 'function' ) {
console.warn('redis pubsub unavailable; duplicate client is not supported');
return;
}
try {
this.#redisSubscriber = redisClient.duplicate();
this.#redisSubscriber.on('error', error => {
console.warn('redis pubsub subscriber error', { error });
});
this.#redisSubscriber.on('message', (channel, message) => {
this.#handleRedisPubSubMessage(channel, message).catch(error => {
console.warn('redis pubsub message handling error', { error });
});
});
await this.#redisSubscriber.subscribe(this.#redisPubSubChannel);
} catch ( error ) {
console.warn('failed to initialize redis pubsub subscriber', { error });
this.#redisSubscriber = null;
}
}
#isRedisWebhookEventKey (key) {
if ( typeof key !== 'string' ) return false;
return key === 'outer.pub' ||
key.startsWith('outer.pub.');
}
#filterRedisWebhookEvents (events) {
return events.filter(event => this.#isRedisWebhookEventKey(event?.key));
}
async #publishWebhookEventsToRedis (events) {
if ( !Array.isArray(events) || events.length === 0 ) return;
const eventsToPublish = this.#filterRedisWebhookEvents(events);
if ( eventsToPublish.length === 0 ) return;
let payload;
try {
payload = JSON.stringify({
sourceId: this.#redisSourceId,
events: eventsToPublish,
});
} catch ( error ) {
console.warn('redis pubsub publish failed: payload not serializable', { error });
return;
}
try {
await redisClient.publish(this.#redisPubSubChannel, payload);
} catch ( error ) {
console.warn('redis pubsub publish failed', { error });
}
}
async #handleRedisPubSubMessage (channel, message) {
if ( channel !== this.#redisPubSubChannel ) return;
let payload;
try {
payload = JSON.parse(message);
} catch {
console.warn('invalid redis pubsub payload: not json');
return;
}
if ( !payload || typeof payload !== 'object' || Array.isArray(payload) ) {
console.warn('invalid redis pubsub payload: expected object');
return;
}
if ( payload.sourceId && payload.sourceId === this.#redisSourceId ) {
return;
}
const incomingEvents = this.#normalizeIncomingPayload(payload);
if ( ! incomingEvents ) {
console.warn('invalid redis pubsub payload: invalid events');
return;
}
const eventsToEmit = this.#filterRedisWebhookEvents(incomingEvents);
if ( eventsToEmit.length === 0 ) return;
await this.#emitIncomingEventsSequentially(eventsToEmit);
}
#normalizeIncomingPayload (payload) {
if ( !payload || typeof payload !== 'object' || Array.isArray(payload) ) {
return null;
}
if ( Array.isArray(payload.events) ) {
const events = [];
for ( const event of payload.events ) {
const normalized = this.#normalizeIncomingEvent(event);
if ( ! normalized ) return null;
events.push(normalized);
}
return events;
}
const normalized = this.#normalizeIncomingEvent(payload);
if ( ! normalized ) return null;
return [normalized];
}
#normalizeIncomingEvent (event) {
if ( !event || typeof event !== 'object' || Array.isArray(event) ) {
return null;
}
const { key, data } = event;
if ( key === undefined || key === null ) {
return null;
}
if ( data === undefined ) {
return null;
}
return {
key,
data,
meta: this.#normalizeMeta(event.meta),
};
}
async #emitIncomingEventsSequentially (events) {
const svcEvent = this.services.get('event');
const context = Context.get(undefined, { allow_fallback: true });
for ( const event of events ) {
if ( event.meta?.from_outside ) {
console.warn('possible over-sending');
continue;
}
if ( event.key === 'test' ) {
console.debug(`test message: ${JSON.stringify(event.data)}`);
}
const metaOut = { ...event.meta, from_outside: true };
await context.arun(async () => {
await svcEvent.emit(event.key, event.data, metaOut);
});
}
}
async '__on_install.routes' (_, { app }) {
const svc_web = this.services.get('web-server');
svc_web.allow_undefined_origin('/broadcast/webhook');
// TODO DS: stop using Endpoint
Endpoint({
route: '/broadcast/webhook',
methods: ['POST'],
handler: this.#handleWebhookRequest.bind(this),
}).attach(app);
}
async #handleWebhookRequest (req, res) {
const rawBody = req.rawBody;
if ( rawBody === undefined || rawBody === null ) {
res.status(400).send({ error: { message: 'Missing or invalid body' } });
return;
}
const body = req.body;
if ( !body || typeof body !== 'object' ) {
res.status(400).send({ error: { message: 'Invalid JSON body' } });
return;
}
const incomingEvents = this.#normalizeIncomingPayload(body);
if ( ! incomingEvents ) {
res.status(400).send({ error: { message: 'Invalid broadcast payload' } });
return;
}
const peerIdHeader = req.headers['x-broadcast-peer-id'];
const peerId = Array.isArray(peerIdHeader) ? peerIdHeader[0] : peerIdHeader;
if ( ! peerId ) {
res.status(403).send({ error: { message: 'Missing X-Broadcast-Peer-Id' } });
return;
}
const localPeerId = this.#resolveLocalPeerId();
if ( localPeerId && peerId === localPeerId ) {
res.status(200).send({ ok: true, ignored: 'self-peer' });
return;
}
const peer = this.#peersByKey[peerId];
if ( !peer || !peer.webhook_secret ) {
res.status(403).send({ error: { message: 'Unknown peer or webhook not configured' } });
return;
}
// Timestamp avoids nonce-reuse after a restart
const timestampHeader = req.headers['x-broadcast-timestamp'];
if ( ! timestampHeader ) {
res.status(400).send({ error: { message: 'Missing X-Broadcast-Timestamp' } });
return;
}
const timestamp = Number(timestampHeader);
if ( Number.isNaN(timestamp) ) {
res.status(400).send({ error: { message: 'Invalid X-Broadcast-Timestamp' } });
return;
}
const nowSeconds = Math.floor(Date.now() / 1000);
const window = this.#webhookReplayWindowSeconds;
if ( timestamp < nowSeconds - window || timestamp > nowSeconds + 60 ) {
res.status(400).send({ error: { message: 'Timestamp out of window' } });
return;
}
// Nonce avoids replay attacks
const nonceHeader = req.headers['x-broadcast-nonce'];
if ( nonceHeader === undefined || nonceHeader === null || nonceHeader === '' ) {
res.status(400).send({ error: { message: 'Missing X-Broadcast-Nonce' } });
return;
}
const nonce = Number(nonceHeader);
if ( Number.isNaN(nonce) ) {
res.status(400).send({ error: { message: 'Invalid X-Broadcast-Nonce' } });
return;
}
if ( this.#isNonceReplayForPeer({ timestamp, nonce, peerId }) ) {
res.status(403).send({ error: { message: 'Duplicate or stale nonce' } });
return;
}
// We verify a signature to ensure the message came from an authorized peer
const signatureHeader = req.headers['x-broadcast-signature'];
if ( ! signatureHeader ) {
res.status(403).send({ error: { message: 'Missing X-Broadcast-Signature' } });
return;
}
const payloadToSign = `${timestamp}.${nonce}.${rawBody}`;
const expectedHmac = createHmac('sha256', peer.webhook_secret).update(payloadToSign).digest('hex');
const signatureBuffer = Buffer.from(signatureHeader, 'hex');
const expectedBuffer = Buffer.from(expectedHmac, 'hex');
if ( signatureBuffer.length !== expectedBuffer.length || !timingSafeEqual(signatureBuffer, expectedBuffer) ) {
res.status(403).send({ error: { message: 'Invalid signature' } });
return;
}
this.#incomingLastNonceByPeer.set(peerId, { timestamp, nonce });
await this.#publishWebhookEventsToRedis(incomingEvents);
await this.#emitIncomingEventsSequentially(incomingEvents);
res.status(200).send({ ok: true });
}
async #sendWebhookToPeer (peer_config, events) {
const peerId = this.#resolvePeerId(peer_config);
if ( ! peerId ) return;
const url = peer_config.webhook_url;
const requestUrl = this.#normalizeWebhookUrl(url);
const mySecretKey = this.config.webhook?.secret ?? '';
if ( !requestUrl || !mySecretKey ) return;
let nextNonce = this.#outgoingNonceByPeer.get(peerId) ?? 0;
this.#outgoingNonceByPeer.set(peerId, nextNonce + 1);
const timestamp = Math.floor(Date.now() / 1000);
const body = { events };
const rawBody = JSON.stringify(body);
const payloadToSign = `${timestamp}.${nextNonce}.${rawBody}`;
const signature = createHmac('sha256', mySecretKey).update(payloadToSign).digest('hex');
const myPublicKey = this.config.webhook?.peerId ?? this.config.webhook?.key ?? '';
const headers = {
'Content-Type': 'application/json',
'Content-Length': String(Buffer.byteLength(rawBody)),
'X-Broadcast-Peer-Id': myPublicKey,
'X-Broadcast-Timestamp': String(timestamp),
'X-Broadcast-Nonce': String(nextNonce),
'X-Broadcast-Signature': signature,
...(this.#webhookHostHeader ? { Host: this.#webhookHostHeader } : {}),
};
const response = await axios.request({
method: 'POST',
url: requestUrl,
headers,
data: rawBody,
timeout: 15000,
validateStatus: () => true,
responseType: 'text',
transformResponse: value => value,
...(requestUrl.startsWith('https:')
? { httpsAgent: this.#webhookHttpsAgent }
: {}),
});
if ( response.status < 200 || response.status >= 300 ) {
console.warn(`error with body: ${response.data}`);
throw new Error(`Webhook POST failed: ${response.status} ${response.statusText}`);
}
}
#normalizeWebhookUrl (url) {
if ( typeof url !== 'string' || url.trim() === '' ) {
return null;
}
const urlValue = url.trim();
let parsedUrl;
try {
parsedUrl = urlValue.includes('://')
? new URL(urlValue)
: new URL(`${this.#webhookProtocol}://${urlValue}`);
} catch {
return null;
}
parsedUrl.protocol = `${this.#webhookProtocol}:`;
return parsedUrl.toString();
}
}
================================================
FILE: src/backend/src/modules/broadcast/BroadcastService.redisPubSub.test.js
================================================
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { redisClient } from '../../clients/redis/redisSingleton.js';
import { BroadcastService } from './BroadcastService.js';
const wait = (ms = 20) => new Promise(resolve => setTimeout(resolve, ms));
describe('BroadcastService redis pubsub', () => {
let eventService;
let service;
beforeAll(async () => {
eventService = {
on: vi.fn(),
emit: vi.fn(async () => {
}),
};
service = new BroadcastService({
services: {
get: (name) => {
if ( name === 'event' ) return eventService;
throw new Error(`unexpected service lookup: ${name}`);
},
},
config: {
domain: 'puter.com',
protocol: 'https',
server_id: 'test-broadcast-a',
services: {
broadcast: {
peers: [],
},
},
},
name: 'broadcast',
args: {},
context: {
get: () => ({ use: () => ({}) }),
},
});
await service._init();
});
afterAll(async () => {
});
beforeEach(() => {
eventService.emit.mockClear();
});
it('re-emits only outer.pub events from redis pubsub payloads', async () => {
await redisClient.publish('broadcast.webhook.events', JSON.stringify({
sourceId: 'other-instance',
events: [
{ key: 'outer.gui.notif.message', data: { id: 'gui-1' }, meta: {} },
{ key: 'outer.pub.notice', data: { id: 'pub-1' }, meta: {} },
{ key: 'outer.cacheUpdate', data: { cacheKey: 'skip-me' }, meta: {} },
],
}));
await wait();
expect(eventService.emit).toHaveBeenCalledTimes(1);
expect(eventService.emit).toHaveBeenNthCalledWith(
1,
'outer.pub.notice',
{ id: 'pub-1' },
expect.objectContaining({ from_outside: true }),
);
});
it('ignores malformed redis pubsub payloads', async () => {
await redisClient.publish('broadcast.webhook.events', 'not-json');
await wait();
await redisClient.publish('broadcast.webhook.events', JSON.stringify({
sourceId: 'other-instance',
events: [{ bad: 'shape' }],
}));
await wait();
expect(eventService.emit).not.toHaveBeenCalled();
});
it('publishes local outer.pub events to redis pubsub for replicas', async () => {
const publishSpy = vi.spyOn(redisClient, 'publish');
try {
await service.outBroadcastEventHandler('outer.pub.notice', { id: 'pub-local' }, {});
await wait();
const publishCall = publishSpy.mock.calls.find(([channel]) => channel === 'broadcast.webhook.events');
expect(publishCall).toBeDefined();
const [channel, payload] = publishCall;
expect(channel).toBe('broadcast.webhook.events');
const parsedPayload = JSON.parse(payload);
expect(parsedPayload.sourceId).toBeDefined();
expect(parsedPayload.events).toEqual([
{
key: 'outer.pub.notice',
data: { id: 'pub-local' },
meta: {},
},
]);
} finally {
publishSpy.mockRestore();
}
});
it('does not publish local outer.gui events to redis pubsub', async () => {
const publishSpy = vi.spyOn(redisClient, 'publish');
try {
await service.outBroadcastEventHandler('outer.gui.notif.message', { id: 'gui-local' }, {});
await wait();
const publishCall = publishSpy.mock.calls.find(([channel]) => channel === 'broadcast.webhook.events');
expect(publishCall).toBeUndefined();
} finally {
publishSpy.mockRestore();
}
});
it('does not rebroadcast events marked from_outside', async () => {
const publishSpy = vi.spyOn(redisClient, 'publish');
try {
await service.outBroadcastEventHandler('outer.gui.notif.message', { id: 'outside' }, {
from_outside: true,
});
await wait();
expect(publishSpy).not.toHaveBeenCalled();
} finally {
publishSpy.mockRestore();
}
});
it('ignores redis pubsub payloads with this instance sourceId', async () => {
const publishSpy = vi.spyOn(redisClient, 'publish');
try {
await service.outBroadcastEventHandler('outer.pub.notice', { id: 'self-source' }, {});
await wait();
const publishCall = publishSpy.mock.calls.find(([channel]) => channel === 'broadcast.webhook.events');
expect(publishCall).toBeDefined();
const [_channel, payload] = publishCall;
eventService.emit.mockClear();
await redisClient.publish('broadcast.webhook.events', payload);
await wait();
expect(eventService.emit).not.toHaveBeenCalled();
} finally {
publishSpy.mockRestore();
}
});
});
================================================
FILE: src/backend/src/modules/captcha/CaptchaModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const CaptchaService = require('./services/CaptchaService');
/**
* @class CaptchaModule
* @extends AdvancedBase
* @description Module that provides captcha verification functionality to protect
* against automated abuse, particularly for login and signup flows. Registers
* a CaptchaService for generating and verifying captchas as well as middlewares
* that can be used to protect routes and determine captcha requirements.
*/
class CaptchaModule extends AdvancedBase {
async install (context) {
// Get services from context
const services = context.get('services');
// Register the captcha service
services.registerService('captcha', CaptchaService);
}
}
module.exports = { CaptchaModule };
================================================
FILE: src/backend/src/modules/captcha/README.md
================================================
# Captcha Module
This module provides captcha verification functionality to protect against automated abuse, particularly for login and signup flows.
## Components
- **CaptchaModule.js**: Registers the service and middleware
- **CaptchaService.js**: Provides captcha generation and verification functionality
- **captcha-middleware.js**: Express middleware for protecting routes with captcha verification
## Integration
The CaptchaService is registered by the CaptchaModule and can be accessed by other services:
```javascript
const captchaService = services.get('captcha');
```
### Example Usage
```javascript
// Generate a captcha
const captcha = captchaService.generateCaptcha();
// captcha.token - The token to verify later
// captcha.image - SVG image data to display to the user
// Verify a captcha
const isValid = captchaService.verifyCaptcha(token, userAnswer);
```
## Configuration
The CaptchaService can be configured with the following options in the configuration file (`config.json`):
- `captcha.enabled`: Whether the captcha service is enabled (default: false)
- `captcha.expirationTime`: How long captcha tokens are valid in milliseconds (default: 10 minutes)
- `captcha.difficulty`: The difficulty level of the captcha ('easy', 'medium', 'hard') (default: 'medium')
These options are set in the main configuration file. For example:
```json
{
"services": {
"captcha": {
"enabled": false,
"expirationTime": 600000,
"difficulty": "medium"
}
}
}
```
### Development Configuration
For local development, you can disable captcha by creating or modifying your local configuration file (e.g., in `volatile/config/config.json` or using a profile configuration):
```json
{
"$version": "v1.1.0",
"$requires": [
"config.json"
],
"config_name": "local",
"services": {
"captcha": {
"enabled": false
}
}
}
```
These options are set when registering the service in CaptchaModule.js.
================================================
FILE: src/backend/src/modules/captcha/middleware/README.md
================================================
# Captcha Middleware
This middleware provides captcha verification for routes that need protection against automated abuse.
## Middleware Components
The captcha system is now split into two middleware components:
1. **checkCaptcha**: Determines if captcha verification is required but doesn't perform verification.
2. **requireCaptcha**: Performs actual captcha verification based on the result from checkCaptcha.
This split allows frontend applications to know in advance whether captcha verification will be needed for a particular action.
## Usage Patterns
### Using Both Middlewares (Recommended)
For best user experience, use both middlewares together:
```javascript
const express = require('express');
const router = express.Router();
// Get both middleware components from the context
const { checkCaptcha, requireCaptcha } = context.get('captcha-middleware');
// Determine if captcha is required for this route
router.post('/login', checkCaptcha({ eventType: 'login' }), (req, res, next) => {
// Set a flag in the response so frontend knows if captcha is needed
res.locals.captchaRequired = req.captchaRequired;
next();
}, requireCaptcha(), (req, res) => {
// Handle login logic
// If captcha was required, it has been verified at this point
});
```
### Using Individual Middlewares
You can also access each middleware separately:
```javascript
const checkCaptcha = context.get('check-captcha-middleware');
const requireCaptcha = context.get('require-captcha-middleware');
```
### Using Only requireCaptcha (Legacy Mode)
For backward compatibility, you can still use only the requireCaptcha middleware:
```javascript
const requireCaptcha = context.get('require-captcha-middleware');
// Always require captcha for this route
router.post('/sensitive-route', requireCaptcha({ always: true }), (req, res) => {
// Route handler
});
// Conditionally require captcha based on extensions
router.post('/normal-route', requireCaptcha(), (req, res) => {
// Route handler
});
```
## Configuration Options
### checkCaptcha Options
- `always` (boolean): Always require captcha regardless of other factors
- `strictMode` (boolean): If true, fails closed on errors (more secure)
- `eventType` (string): Type of event for extensions (e.g., 'login', 'signup')
### requireCaptcha Options
- `strictMode` (boolean): If true, fails closed on errors (more secure)
## Frontend Integration
There are two ways to integrate with the frontend:
### 1. Using the checkCaptcha Result in API Responses
You can include the captcha requirement in API responses:
```javascript
router.get('/whoarewe', checkCaptcha({ eventType: 'login' }), (req, res) => {
res.json({
// Other environment information
captchaRequired: {
login: req.captchaRequired
}
});
});
```
### 2. Setting GUI Parameters
For PuterHomepageService, you can add captcha requirements to GUI parameters:
```javascript
// In PuterHomepageService.js
gui_params: {
// Other parameters
captchaRequired: {
login: req.captchaRequired
}
}
```
## Client-Side Integration
To integrate with the captcha middleware, the client needs to:
1. Check if captcha is required for the action (using /whoarewe or GUI parameters)
2. If required, call the `/api/captcha/generate` endpoint to get a captcha token and image
3. Display the captcha image to the user and collect their answer
4. Include the captcha token and answer in the request body:
```javascript
// Example client-side code
async function submitWithCaptcha(formData) {
// Check if captcha is required
const envInfo = await fetch('/api/whoarewe').then(r => r.json());
if (envInfo.captchaRequired?.login) {
// Get and display captcha to user
const captcha = await getCaptchaFromServer();
showCaptchaToUser(captcha);
// Add captcha token and answer to the form data
formData.captchaToken = captcha.token;
formData.captchaAnswer = await getUserCaptchaAnswer();
}
// Submit the form
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
// Handle response
const data = await response.json();
if (response.status === 400 && data.error === 'captcha_required') {
// Show captcha to the user if not already shown
showCaptcha();
}
}
```
## Error Handling
The middleware will throw the following errors:
- `captcha_required`: When captcha verification is required but no token or answer was provided.
- `captcha_invalid`: When the provided captcha answer is incorrect.
These errors can be caught by the API error handler and returned to the client.
================================================
FILE: src/backend/src/modules/captcha/middleware/captcha-middleware.js
================================================
/*
* 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 .
*/
const APIError = require('../../../api/APIError');
const { Context } = require('../../../util/context');
/**
* Middleware that checks if captcha verification is required
* This is the "first half" of the captcha verification process
* It determines if verification is needed but doesn't perform verification
*
* @param {Object} options - Configuration options
* @param {boolean} [options.strictMode=true] - If true, fails closed on errors (more secure)
* @returns {Function} Express middleware function
*/
const checkCaptcha = ({ svc_captcha }) => async (req, res, next) => {
// Get services from the Context
const services = Context.get('services');
if ( ! svc_captcha.enabled ) {
req.captchaRequired = false;
return next();
}
const ip = req.headers?.['x-forwarded-for'] ||
req.connection?.remoteAddress;
const svc_event = services.get('event');
const event = {
ip,
// By default, captcha always appears if enabled
required: true,
};
await svc_event.emit('captcha.check', event);
// Set captcha requirement based on service status
req.captchaRequired = event.required;
next();
};
/**
* Middleware that requires captcha verification
* This is the "second half" of the captcha verification process
* It uses the result from checkCaptcha to determine if verification is needed
*
* @param {Object} options - Configuration options
* @param {boolean} [options.strictMode=true] - If true, fails closed on errors (more secure)
* @returns {Function} Express middleware function
*/
const requireCaptcha = (options = {}) => async (req, res, next) => {
if ( ! req.captchaRequired ) {
return next();
}
const services = Context.get('services');
try {
let captchaService;
try {
captchaService = services.get('captcha');
} catch ( error ) {
console.warn('Captcha verification: required service not available', error);
return next(APIError.create('internal_error', null, {
message: 'Captcha service unavailable',
status: 503,
}));
}
// Fail closed if captcha service doesn't exist or isn't properly initialized
if ( !captchaService || typeof captchaService.verifyCaptcha !== 'function' ) {
return next(APIError.create('internal_error', null, {
message: 'Captcha service misconfigured',
status: 500,
}));
}
// Check for captcha token and answer in request
const captchaToken = req.body.captchaToken;
const captchaAnswer = req.body.captchaAnswer;
if ( !captchaToken || !captchaAnswer ) {
return next(APIError.create('captcha_required', null, {
message: 'Captcha verification required',
status: 400,
}));
}
// Verify the captcha
let isValid;
try {
isValid = captchaService.verifyCaptcha(captchaToken, captchaAnswer);
} catch ( verifyError ) {
console.error('Captcha verification: threw an error', verifyError);
return next(APIError.create('captcha_invalid', null, {
message: 'Captcha verification failed',
status: 400,
}));
}
// Check verification result
if ( ! isValid ) {
return next(APIError.create('captcha_invalid', null, {
message: 'Invalid captcha response',
status: 400,
}));
}
// Captcha verified successfully, continue
next();
} catch ( error ) {
console.error('Captcha verification: unexpected error', error);
return next(APIError.create('internal_error', null, {
message: 'Captcha verification failed',
status: 500,
}));
}
};
module.exports = {
checkCaptcha,
requireCaptcha,
};
================================================
FILE: src/backend/src/modules/captcha/services/CaptchaService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../../services/BaseService');
const { Endpoint } = require('../../../util/expressutil');
const { checkCaptcha } = require('../middleware/captcha-middleware');
/**
* @class CaptchaService
* @extends BaseService
* @description Service that provides captcha generation and verification functionality
* to protect against automated abuse. Uses svg-captcha for generation and maintains
* a token-based verification system.
*/
class CaptchaService extends BaseService {
/**
* Initializes the captcha service with configuration and storage
*/
async _construct () {
// Load dependencies
this.crypto = require('crypto');
this.svgCaptcha = require('svg-captcha');
// In-memory token storage with expiration
this.captchaTokens = new Map();
// Service instance diagnostic tracking
this.serviceId = Math.random().toString(36).substring(2, 10);
this.requestCounter = 0;
// Get configuration from service config
this.enabled = this.config.enabled === true;
this.expirationTime = this.config.expirationTime || (10 * 60 * 1000); // 10 minutes default
this.difficulty = this.config.difficulty || 'medium';
this.testMode = this.config.testMode === true;
// Add a static test token for diagnostic purposes
this.captchaTokens.set('test-static-token', {
text: 'testanswer',
expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000), // 1 year
});
// Flag to track if endpoints are registered
this.endpointsRegistered = false;
}
async '__on_install.middlewares.context-aware' (_, { app }) {
// Add express middleware
app.use(checkCaptcha({ svc_captcha: this }));
}
/**
* Sets up API endpoints and cleanup tasks
*/
async _init () {
if ( ! this.enabled ) {
this.log.debug('Captcha service is disabled');
return;
}
// Set up periodic cleanup
this.cleanupInterval = setInterval(() => this.cleanupExpiredTokens(), 15 * 60 * 1000);
// Register endpoints if not already done
if ( ! this.endpointsRegistered ) {
this.registerEndpoints();
this.endpointsRegistered = true;
}
}
/**
* Cleanup method called when service is being destroyed
*/
async _destroy () {
if ( this.cleanupInterval ) {
clearInterval(this.cleanupInterval);
}
this.captchaTokens.clear();
}
/**
* Registers the captcha API endpoints with the web service
* @private
*/
registerEndpoints () {
if ( this.endpointsRegistered ) {
return;
}
try {
// Try to get the web service
let webService = null;
try {
webService = this.services.get('web-service');
} catch ( error ) {
// Web service not available, try web-server
try {
webService = this.services.get('web-server');
} catch ( innerError ) {
this.log.warn('Neither web-service nor web-server are available yet');
return;
}
}
if ( !webService || !webService.app ) {
this.log.warn('Web service found but app is not available');
return;
}
const app = webService.app;
const api = this.require('express').Router();
app.use('/api/captcha', api);
// Generate captcha endpoint
Endpoint({
route: '/generate',
methods: ['GET'],
handler: async (req, res) => {
const captcha = this.generateCaptcha();
res.json({
token: captcha.token,
image: captcha.data,
});
},
}).attach(api);
// Verify captcha endpoint
Endpoint({
route: '/verify',
methods: ['POST'],
handler: (req, res) => {
const { token, answer } = req.body;
if ( !token || !answer ) {
return res.status(400).json({
valid: false,
error: 'Missing token or answer',
});
}
const isValid = this.verifyCaptcha(token, answer);
res.json({ valid: isValid });
},
}).attach(api);
// Special endpoint for automated testing
// This should be disabled in production
if ( this.testMode ) {
app.post('/api/captcha/create-test-token', (req, res) => {
try {
const { token, answer } = req.body;
if ( !token || !answer ) {
return res.status(400).json({
error: 'Missing token or answer',
});
}
// Store the test token with the provided answer
this.captchaTokens.set(token, {
text: answer.toLowerCase(),
expiresAt: Date.now() + this.expirationTime,
});
this.log.debug(`Created test token: ${token} with answer: ${answer}`);
res.json({ success: true });
} catch ( error ) {
this.log.error(`Error creating test token: ${error.message}`);
res.status(500).json({ error: 'Failed to create test token' });
}
});
}
// Diagnostic endpoint - should be used carefully and only during debugging
app.get('/api/captcha/diagnostic', (req, res) => {
try {
// Get information about the current state
const diagnosticInfo = {
serviceEnabled: this.enabled,
difficulty: this.difficulty,
expirationTime: this.expirationTime,
testMode: this.testMode,
activeTokenCount: this.captchaTokens.size,
serviceId: this.serviceId,
processId: process.pid,
requestCounter: this.requestCounter,
hasStaticTestToken: this.captchaTokens.has('test-static-token'),
tokensState: Array.from(this.captchaTokens).map(([token, data]) => ({
tokenPrefix: `${token.substring(0, 8) }...`,
expiresAt: new Date(data.expiresAt).toISOString(),
expired: data.expiresAt < Date.now(),
expectedAnswer: data.text,
})),
};
res.json(diagnosticInfo);
} catch ( error ) {
this.log.error(`Error in diagnostic endpoint: ${error.message}`);
res.status(500).json({ error: 'Diagnostic error' });
}
});
// Advanced token debugging endpoint - allows testing
app.get('/api/captcha/debug-tokens', (req, res) => {
try {
// Check if we're the same service instance
const currentTimestamp = Date.now();
const currentTokens = Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8));
// Create a test token that won't expire soon
const debugToken = `debug-${ this.crypto.randomBytes(8).toString('hex')}`;
const debugAnswer = 'test123';
this.captchaTokens.set(debugToken, {
text: debugAnswer,
expiresAt: currentTimestamp + (60 * 60 * 1000), // 1 hour
});
// Information about the current service instance
const serviceInfo = {
message: 'Debug token created - use for testing captcha validation',
serviceId: this.serviceId,
debugToken: debugToken,
debugAnswer: debugAnswer,
tokensBefore: currentTokens,
tokensAfter: Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8)),
currentTokenCount: this.captchaTokens.size,
timestamp: currentTimestamp,
processId: process.pid,
};
res.json(serviceInfo);
} catch ( error ) {
this.log.error(`Error in debug-tokens endpoint: ${error.message}`);
res.status(500).json({ error: 'Debug token creation error' });
}
});
// Configuration verification endpoint
app.get('/api/captcha/config-status', (req, res) => {
try {
// Information about configuration states
const configInfo = {
serviceEnabled: this.enabled,
serviceDifficulty: this.difficulty,
configSource: 'Service configuration',
centralConfig: {
enabled: this.enabled,
difficulty: this.difficulty,
expirationTime: this.expirationTime,
testMode: this.testMode,
},
usingCentralizedConfig: true,
configConsistency: this.enabled === (this.enabled === true),
serviceId: this.serviceId,
processId: process.pid,
};
res.json(configInfo);
} catch ( error ) {
this.log.error(`Error in config-status endpoint: ${error.message}`);
res.status(500).json({ error: 'Configuration status error' });
}
});
// Test endpoint to validate token lifecycle
app.get('/api/captcha/test-lifecycle', (req, res) => {
try {
// Create a test captcha
const testText = 'test123';
const testToken = `lifecycle-${ this.crypto.randomBytes(16).toString('hex')}`;
// Store the test token
this.captchaTokens.set(testToken, {
text: testText,
expiresAt: Date.now() + this.expirationTime,
});
// Verify the token exists
const tokenExists = this.captchaTokens.has(testToken);
// Try to verify with correct answer
const correctVerification = this.verifyCaptcha(testToken, testText);
// Check if token was deleted after verification
const tokenAfterVerification = this.captchaTokens.has(testToken);
// Create another test token
const testToken2 = `lifecycle2-${ this.crypto.randomBytes(16).toString('hex')}`;
// Store the test token
this.captchaTokens.set(testToken2, {
text: testText,
expiresAt: Date.now() + this.expirationTime,
});
res.json({
message: 'Token lifecycle test completed',
serviceId: this.serviceId,
initialTokens: this.captchaTokens.size - 2, // minus the two we added
tokenCreated: true,
tokenExisted: tokenExists,
verificationResult: correctVerification,
tokenRemovedAfterVerification: !tokenAfterVerification,
secondTokenCreated: this.captchaTokens.has(testToken2),
processId: process.pid,
});
} catch ( error ) {
console.error('TOKENS_TRACKING: Error in test-lifecycle endpoint:', error);
res.status(500).json({ error: 'Test lifecycle error' });
}
});
this.endpointsRegistered = true;
this.log.debug('Captcha service endpoints registered successfully');
// Emit an event that captcha service is ready
try {
const eventService = this.services.get('event');
if ( eventService ) {
eventService.emit('service-ready', 'captcha');
}
} catch ( error ) {
// Ignore errors with event service
}
} catch ( error ) {
this.log.warn(`Could not register captcha endpoints: ${error.message}`);
}
}
/**
* Generates a new captcha with a unique token
* @returns {Object} Object containing token and SVG image
*/
generateCaptcha () {
console.log('====== CAPTCHA GENERATION DIAGNOSTIC ======');
console.log('TOKENS_TRACKING: generateCaptcha called. Service ID:', this.serviceId);
console.log('TOKENS_TRACKING: Token map size before generation:', this.captchaTokens.size);
console.log('TOKENS_TRACKING: Static test token exists:', this.captchaTokens.has('test-static-token'));
// Increment request counter for diagnostics
this.requestCounter++;
console.log('TOKENS_TRACKING: Request counter value:', this.requestCounter);
console.log('generateCaptcha called, service enabled:', this.enabled);
if ( ! this.enabled ) {
console.log('Generation SKIPPED: Captcha service is disabled');
throw new Error('Captcha service is disabled');
}
// Configure captcha options based on difficulty
const options = this._getCaptchaOptions();
console.log('Using captcha options for difficulty:', this.difficulty);
// Generate the captcha
const captcha = this.svgCaptcha.create(options);
console.log('Captcha created with text:', captcha.text);
// Generate a unique token
const token = this.crypto.randomBytes(32).toString('hex');
console.log('Generated token:', `${token.substring(0, 8) }...`);
// Store token with captcha text and expiration
const expirationTime = Date.now() + this.expirationTime;
console.log('Token will expire at:', new Date(expirationTime));
console.log('TOKENS_TRACKING: Token map size before storing new token:', this.captchaTokens.size);
this.captchaTokens.set(token, {
text: captcha.text.toLowerCase(),
expiresAt: expirationTime,
});
console.log('TOKENS_TRACKING: Token map size after storing new token:', this.captchaTokens.size);
console.log('Token stored in captchaTokens. Current token count:', this.captchaTokens.size);
this.log.debug(`Generated captcha with token: ${token}`);
return {
token: token,
data: captcha.data,
};
}
/**
* Verifies a captcha answer against a stored token
* @param {string} token - The captcha token
* @param {string} userAnswer - The user's answer to verify
* @returns {boolean} Whether the answer is valid
*/
verifyCaptcha (token, userAnswer) {
console.debug('====== CAPTCHA SERVICE VERIFICATION DIAGNOSTIC ======');
console.debug('TOKENS_TRACKING: verifyCaptcha called. Service ID:', this.serviceId);
console.debug('TOKENS_TRACKING: Request counter during verification:', this.requestCounter);
console.debug('TOKENS_TRACKING: Static test token exists:', this.captchaTokens.has('test-static-token'));
console.debug('TOKENS_TRACKING: Trying to verify token:', token ? `${token.substring(0, 8) }...` : 'undefined');
console.debug('verifyCaptcha called with token:', token ? `${token.substring(0, 8) }...` : 'undefined');
console.debug('userAnswer:', userAnswer);
console.debug('Service enabled:', this.enabled);
console.debug('Number of tokens in captchaTokens:', this.captchaTokens.size);
// Service health check
this._checkServiceHealth();
if ( ! this.enabled ) {
console.log('Verification SKIPPED: Captcha service is disabled');
this.log.warn('Captcha verification attempted while service is disabled');
throw new Error('Captcha service is disabled');
}
// Get captcha data for token
const captchaData = this.captchaTokens.get(token);
console.log('Captcha data found for token:', !!captchaData);
// Invalid token or expired
if ( ! captchaData ) {
console.log('Verification FAILED: No data found for this token');
console.log('TOKENS_TRACKING: Available tokens (first 8 chars):',
Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8)));
this.log.debug(`Invalid captcha token: ${token}`);
return false;
}
if ( captchaData.expiresAt < Date.now() ) {
console.log('Verification FAILED: Token expired at:', new Date(captchaData.expiresAt));
this.log.debug(`Expired captcha token: ${token}`);
return false;
}
// Normalize and compare answers
const normalizedUserAnswer = userAnswer.toLowerCase().trim();
console.log('Expected answer:', captchaData.text);
console.log('User answer (normalized):', normalizedUserAnswer);
const isValid = captchaData.text === normalizedUserAnswer;
console.log('Answer comparison result:', isValid);
// Remove token after verification (one-time use)
this.captchaTokens.delete(token);
console.log('Token removed after verification (one-time use)');
console.log('TOKENS_TRACKING: Token map size after removing used token:', this.captchaTokens.size);
this.log.debug(`Verified captcha token: ${token}, valid: ${isValid}`);
return isValid;
}
/**
* Simple diagnostic method to check service health
* @private
*/
_checkServiceHealth () {
console.log('TOKENS_TRACKING: Service health check. ID:', this.serviceId, 'Token count:', this.captchaTokens.size);
return true;
}
/**
* Removes expired captcha tokens from memory
*/
cleanupExpiredTokens () {
console.log('TOKENS_TRACKING: Running token cleanup. Service ID:', this.serviceId);
console.log('TOKENS_TRACKING: Token map size before cleanup:', this.captchaTokens.size);
const now = Date.now();
let expiredCount = 0;
let validCount = 0;
// Log all tokens before cleanup
console.log('TOKENS_TRACKING: Current tokens before cleanup:');
for ( const [token, data] of this.captchaTokens.entries() ) {
const isExpired = data.expiresAt < now;
console.log(`TOKENS_TRACKING: Token ${token.substring(0, 8)}... expires: ${new Date(data.expiresAt).toISOString()}, expired: ${isExpired}`);
if ( isExpired ) {
expiredCount++;
} else {
validCount++;
}
}
// Only do the actual cleanup if we found expired tokens
if ( expiredCount > 0 ) {
console.log(`TOKENS_TRACKING: Found ${expiredCount} expired tokens to remove and ${validCount} valid tokens to keep`);
// Clean up expired tokens
for ( const [token, data] of this.captchaTokens.entries() ) {
if ( data.expiresAt < now ) {
this.captchaTokens.delete(token);
console.log(`TOKENS_TRACKING: Deleted expired token: ${token.substring(0, 8)}...`);
}
}
} else {
console.log('TOKENS_TRACKING: No expired tokens found, skipping cleanup');
}
// Skip cleanup for the static test token
if ( this.captchaTokens.has('test-static-token') ) {
console.log('TOKENS_TRACKING: Static test token still exists after cleanup');
} else {
console.log('TOKENS_TRACKING: WARNING - Static test token was removed during cleanup');
// Restore the static test token for diagnostic purposes
this.captchaTokens.set('test-static-token', {
text: 'testanswer',
expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000), // 1 year
});
console.log('TOKENS_TRACKING: Restored static test token');
}
console.log('TOKENS_TRACKING: Token map size after cleanup:', this.captchaTokens.size);
if ( expiredCount > 0 ) {
this.log.debug(`Cleaned up ${expiredCount} expired captcha tokens`);
}
}
/**
* Gets captcha options based on the configured difficulty
* @private
* @returns {Object} Captcha configuration options
*/
_getCaptchaOptions () {
const baseOptions = {
size: 6, // Default captcha length
ignoreChars: '0o1ilI', // Characters to avoid (confusing)
noise: 2, // Lines to add as noise
color: true,
background: '#f0f0f0',
};
switch ( this.difficulty ) {
case 'easy':
return {
...baseOptions,
size: 4,
width: 150,
height: 50,
noise: 1,
};
case 'hard':
return {
...baseOptions,
size: 7,
width: 200,
height: 60,
noise: 3,
};
case 'medium':
default:
return {
...baseOptions,
width: 180,
height: 50,
};
}
}
/**
* Verifies that the captcha service is properly configured and working
* This is used during initialization and can be called to check system status
* @returns {boolean} Whether the service is properly configured and functioning
*/
verifySelfTest () {
try {
// Ensure required dependencies are available
if ( ! this.svgCaptcha ) {
this.log.error('Captcha service self-test failed: svg-captcha module not available');
return false;
}
if ( ! this.enabled ) {
this.log.warn('Captcha service self-test failed: service is disabled');
return false;
}
// Validate configuration
if ( !this.expirationTime || typeof this.expirationTime !== 'number' ) {
this.log.error('Captcha service self-test failed: invalid expiration time configuration');
return false;
}
// Basic functionality test - generate a test captcha and verify storage
const testToken = `test-${ this.crypto.randomBytes(8).toString('hex')}`;
const testText = 'testcaptcha';
// Store the test captcha
this.captchaTokens.set(testToken, {
text: testText,
expiresAt: Date.now() + this.expirationTime,
});
// Verify the test captcha
const correctVerification = this.verifyCaptcha(testToken, testText);
// Check if verification worked and token was removed
if ( !correctVerification || this.captchaTokens.has(testToken) ) {
this.log.error('Captcha service self-test failed: verification test failed');
return false;
}
this.log.debug('Captcha service self-test passed');
return true;
} catch ( error ) {
this.log.error(`Captcha service self-test failed with error: ${error.message}`);
return false;
}
}
/**
* Returns the service's diagnostic information
* @returns {Object} Diagnostic information about the service
*/
getDiagnosticInfo () {
return {
serviceId: this.serviceId,
enabled: this.enabled,
tokenCount: this.captchaTokens.size,
requestCounter: this.requestCounter,
config: {
enabled: this.enabled,
difficulty: this.difficulty,
expirationTime: this.expirationTime,
testMode: this.testMode,
},
processId: process.pid,
testTokenExists: this.captchaTokens.has('test-static-token'),
};
}
}
// Export both as a named export and as a default export for compatibility
module.exports = CaptchaService;
module.exports.CaptchaService = CaptchaService;
================================================
FILE: src/backend/src/modules/core/AlarmService.d.ts
================================================
export class AlarmService {
create (id: string, message: string, fields?: object): void;
clear (id: string): void;
get_alarm (id: string): object | undefined;
// Add more methods/properties as needed for MeteringService usage
}
================================================
FILE: src/backend/src/modules/core/AlarmService.js
================================================
/*
* 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 .
*/
const seedrandom = require('seedrandom');
const util = require('util');
const fs = require('fs');
const BaseService = require('../../services/BaseService.js');
/**
* AlarmService class is responsible for managing alarms.
* It provides methods for creating, clearing, and handling alarms.
*/
class AlarmService extends BaseService {
static USE = {
logutil: 'core.util.logutil',
identutil: 'core.util.identutil',
stdioutil: 'core.util.stdioutil',
Context: 'core.context',
};
/**
* This method initializes the AlarmService by setting up its internal data structures and initializing any required dependencies.
*
* It reads in the known errors from a JSON5 file and sets them as the known_errors property of the AlarmService instance.
*/
async _construct () {
this.alarms = {};
this.alarm_aliases = {};
this.known_errors = [];
}
/**
* Method to initialize AlarmService. Sets the known errors and registers commands.
* @returns {Promise}
*/
async _init () {
const services = this.services;
this.pager = services.get('pager');
// TODO:[self-hosted] fix this properly
this.known_errors = [];
}
/**
* AlarmService registers its commands at the consolidation phase because
* the '_init' method of CommandService may not have been called yet.
*/
'__on_boot.consolidation' () {
this._register_commands(this.services.get('commands'));
}
adapt_id_ (id) {
let shorten = true;
if ( shorten ) {
const rng = seedrandom(id);
id = this.identutil.generate_identifier('-', rng);
}
return id;
}
/**
* Method to create an alarm with the given ID, message, and fields.
* If the ID already exists, it will be updated with the new fields
* and the occurrence count will be incremented.
*
* @param {string} id - Unique identifier for the alarm.
* @param {string} message - Message associated with the alarm.
* @param {object} fields - Additional information about the alarm.
*/
create (id, message, fields) {
if ( this.config.log_upcoming_alarms ) {
this.log.error(`upcoming alarm: ${id}: ${message}`);
}
let existing = false;
/**
* Method to create an alarm with the given ID, message, and fields.
* If the ID already exists, it will be updated with the new fields.
* @param {string} id - Unique identifier for the alarm.
* @param {string} message - Message associated with the alarm.
* @param {object} fields - Additional information about the alarm.
* @returns {void}
*/
const alarm = (() => {
const short_id = this.adapt_id_(id);
if ( this.alarms[id] ) {
existing = true;
return this.alarms[id];
}
const alarm = this.alarms[id] = this.alarm_aliases[short_id] = {
id,
short_id,
started: Date.now(),
occurrences: [],
};
Object.defineProperty(alarm, 'count', {
/**
* Method to create a new alarm.
*
* This method takes an id, message, and optional fields as parameters.
* It creates a new alarm object with the provided id and message,
* and adds it to the alarms object. It also keeps track of the number of occurrences of the alarm.
* If the alarm already exists, it increments the occurrence count and calls the handle\_alarm\_repeat\_ method.
* If it's a new alarm, it calls the handle\_alarm\_on\_ method.
*
* @param {string} id - The unique identifier for the alarm.
* @param {string} message - The message associated with the alarm.
* @param {object} [fields] - Optional fields associated with the alarm.
* @returns {void}
*/
get () {
return alarm.timestamps?.length ?? 0;
},
});
Object.defineProperty(alarm, 'id_string', {
/**
* Method to handle creating a new alarm with given parameters.
* This method adds the alarm to the `alarms` object, updates the occurrences count,
* and processes any known errors that may apply to the alarm.
* @param {string} id - The unique identifier for the alarm.
* @param {string} message - The message associated with the alarm.
* @param {Object} fields - Additional fields to associate with the alarm.
*/
get () {
if ( alarm.id.length < 20 ) {
return alarm.id;
}
const truncatedLongId = `${alarm.id.slice(0, 20) }...`;
return `${alarm.short_id} (${truncatedLongId})`;
},
});
return alarm;
})();
const occurance = {
message,
fields,
timestamp: Date.now(),
};
// Keep logs from the previous occurrence if:
// - it's one of the first 3 occurrences
// - the 10th, 100th, 1000th...etc occurrence
if ( alarm.count > 3 && Math.log10(alarm.count) % 1 !== 0 ) {
delete alarm.occurrences[alarm.occurrences.length - 1].logs;
}
occurance.logs = this.log.get_log_buffer();
alarm.message = message;
alarm.fields = { ...alarm.fields, ...fields };
alarm.timestamps = (alarm.timestamps ?? []).concat(Date.now());
alarm.occurrences.push(occurance);
if ( fields?.error ) {
alarm.error = fields.error;
}
if ( alarm.source ) {
console.error(alarm.error);
}
if ( existing ) {
this.handle_alarm_repeat_(alarm);
} else {
this.handle_alarm_on_(alarm);
}
}
/**
* Method to clear an alarm with the given ID.
* @param {*} id - The ID of the alarm to clear.
* @returns {void}
*/
clear (id) {
const alarm = this.alarms[id];
if ( ! alarm ) {
return;
}
delete this.alarms[id];
this.handle_alarm_off_(alarm);
}
apply_known_errors_ (alarm) {
const rule_matches = rule => {
const match = rule.match;
if ( match.id !== alarm.id ) return false;
if ( match.message && match.message !== alarm.message ) return false;
if ( match.fields ) {
for ( const [key, value] of Object.entries(match.fields) ) {
if ( alarm.fields[key] !== value ) return false;
}
}
return true;
};
const rule_actions = {
'no-alert': () => alarm.no_alert = true,
'severity': action => alarm.severity = action.value,
};
const apply_action = action => {
rule_actions[action.type](action);
};
for ( const rule of this.known_errors ) {
if ( rule_matches(rule) ) apply_action(rule.action);
}
}
handle_alarm_repeat_ (alarm) {
this.log.warn(`REPEAT ${alarm.id_string} :: ${alarm.message} (${alarm.count})`,
alarm.fields);
this.apply_known_errors_(alarm);
if ( alarm.no_alert ) return;
const severity = alarm.severity ?? 'critical';
const fields_clean = {};
for ( const [key, value] of Object.entries(alarm.fields) ) {
fields_clean[key] = util.inspect(value);
}
this.pager.alert({
id: alarm.id ?? 'something-bad',
message: alarm.message ?? alarm.id ?? 'something bad happened',
source: 'alarm-service',
severity,
custom: {
fields: fields_clean,
trace: alarm.error?.stack,
repeat_count: alarm.count,
},
});
}
handle_alarm_on_ (alarm) {
this.log.error(`ACTIVE ${alarm.id_string} :: ${alarm.message} (${alarm.count})`,
alarm.fields);
this.apply_known_errors_(alarm);
if ( this.global_config.env === 'dev' && !this.attached_dev ) {
this.attached_dev = true;
const realConsole = globalThis.original_console_object ?? console;
realConsole.error('\x1B[33;1m[alarm]\x1B[0m Active alarms detected; see logs for details.');
}
const args = this.Context.get('args') ?? {};
if ( args['quit-on-alarm'] ) {
const svc_shutdown = this.services.get('shutdown');
svc_shutdown.shutdown({
reason: '--quit-on-alarm is set',
code: 1,
});
}
if ( alarm.no_alert ) return;
const severity = alarm.severity ?? 'critical';
const fields_clean = {};
for ( const [key, value] of Object.entries(alarm.fields) ) {
fields_clean[key] = util.inspect(value);
}
this.pager.alert({
id: alarm.id ?? 'something-bad',
message: alarm.message ?? alarm.id ?? 'something bad happened',
source: 'alarm-service',
severity,
custom: {
fields: fields_clean,
trace: alarm.error?.stack,
},
});
// Write a .log file for the alert that happened
try {
const lines = [];
lines.push(`ALERT ${alarm.id_string} :: ${alarm.message} (${alarm.count})`);
lines.push(`started: ${new Date(alarm.started).toISOString()}`);
lines.push(`short id: ${alarm.short_id}`);
lines.push(`original id: ${alarm.id}`);
lines.push(`severity: ${severity}`);
lines.push(`message: ${alarm.message}`);
lines.push(`fields: ${JSON.stringify(fields_clean)}`);
const alert_info = lines.join('\n');
(async () => {
try {
fs.appendFileSync(`alert_${alarm.id}.log`, `${alert_info }\n`);
} catch (e) {
this.log.error(`failed to write alert log: ${e.message}`);
}
})();
} catch (e) {
this.log.error(`failed to write alert log: ${e.message}`);
}
}
handle_alarm_off_ (alarm) {
this.log.info(`CLEAR ${alarm.id} :: ${alarm.message} (${alarm.count})`,
alarm.fields);
}
/**
* Method to get an alarm by its ID.
*
* @param {*} id - The ID of the alarm to get.
* @returns
*/
get_alarm (id) {
return this.alarms[id] ?? this.alarm_aliases[id];
}
_register_commands (commands) {
// Function to handle a specific alarm event.
// This comment can be added above line 320.
// This function is responsible for processing specific events related to alarms.
// It can be used for tasks such as updating alarm status, sending notifications, or triggering actions.
// This function is called internally by the AlarmService class.
// /*
// * handleAlarmEvent - Handles a specific alarm event.
// *
// * @param {Object} alarm - The alarm object containing relevant information.
// * @param {Function} callback - Optional callback function to be called when the event is handled.
// */
// function handleAlarmEvent(alarm, callback) {
// // Implementation goes here.
// }
const completeAlarmID = (args) => {
// The alarm ID is the first argument, so return no results if we're on the second or later.
if ( args.length > 1 )
{
return;
}
const lastArg = args[args.length - 1];
const results = [];
for ( const alarm of Object.values(this.alarms) ) {
if ( alarm.id.startsWith(lastArg) ) {
results.push(alarm.id);
}
if ( alarm.short_id?.startsWith(lastArg) ) {
results.push(alarm.short_id);
}
}
return results;
};
commands.registerCommands('alarm', [
{
id: 'list',
description: 'list alarms',
handler: async (args, log) => {
for ( const alarm of Object.values(this.alarms) ) {
log.log(`${alarm.id_string}: ${alarm.message} (${alarm.count})`);
}
},
},
{
id: 'info',
description: 'show info about an alarm',
handler: async (args, log) => {
const [id] = args;
const alarm = this.get_alarm(id);
if ( ! alarm ) {
log.log(`no alarm with id ${id}`);
return;
}
log.log(`\x1B[33;1m${alarm.id_string}\x1B[0m :: ${alarm.message} (${alarm.count})`);
log.log(`started: ${new Date(alarm.started).toISOString()}`);
log.log(`short id: ${alarm.short_id}`);
log.log(`original id: ${alarm.id}`);
// print stack trace of alarm error
if ( alarm.error ) {
log.log(alarm.error.stack);
}
// print other fields
for ( const [key, value] of Object.entries(alarm.fields) ) {
log.log(`- ${key}: ${util.inspect(value)}`);
}
},
completer: completeAlarmID,
},
{
id: 'clear',
description: 'clear an alarm',
handler: async (args, log) => {
const [id] = args;
const alarm = this.get_alarm(id);
if ( ! alarm ) {
log.log(`no alarm with id ${id}; ` +
`but calling clear(${JSON.stringify(id)}) anyway.`);
}
this.clear(id);
},
completer: completeAlarmID,
},
{
id: 'clear-all',
description: 'clear all alarms',
handler: async (_args, _log) => {
const alarms = Object.values(this.alarms);
this.alarms = {};
for ( const alarm of alarms ) {
this.handle_alarm_off_(alarm);
}
},
},
{
id: 'sound',
description: 'sound an alarm',
handler: async (args, _log) => {
const [id, message] = args;
this.create(id ?? 'test', message, {});
},
},
{
id: 'inspect',
description: 'show logs that happened an alarm',
handler: async (args, log) => {
const [id, occurance_idx] = args;
const alarm = this.get_alarm(id);
if ( ! alarm ) {
log.log(`no alarm with id ${id}`);
return;
}
const occurance = alarm.occurrences[occurance_idx];
if ( ! occurance ) {
log.log(`no occurance with index ${occurance_idx}`);
return;
}
log.log(`┏━━ Logs before: ${alarm.id_string} ━━━━`);
for ( const lg of occurance.logs ) {
log.log(`┃ ${ this.logutil.stringify_log_entry(lg)}`);
}
log.log(`┗━━ Logs before: ${alarm.id_string} ━━━━`);
},
completer: completeAlarmID,
},
]);
}
}
module.exports = {
AlarmService,
};
================================================
FILE: src/backend/src/modules/core/ContextService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../services/BaseService');
const { Context } = require('../../util/context');
/**
* ContextService provides a way for other services to register a hook to be
* called when a context/subcontext is created.
*
* Contexts are used to provide contextual information in the execution
* context (dynamic scope). They can also be used to identify a "span";
* a span is a labelled frame of execution that can be used to track
* performance, errors, and other metrics.
*/
class ContextService extends BaseService {
register_context_hook (event, hook) {
Context.context_hooks_[event].push(hook);
}
}
module.exports = {
ContextService,
};
================================================
FILE: src/backend/src/modules/core/Core2Module.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
/**
* A replacement for CoreModule with as few external relative requires as possible.
* This will eventually be the successor to CoreModule, the main module for Puter's backend.
*
* The scope of this module is:
* - logging and error handling
* - alarm handling
* - services that are tightly coupled with alarm handling are allowed
* - any essential information about server stats or health
* - any very generic service which other services can register
* behavior to.
*/
class Core2Module extends AdvancedBase {
async install (context) {
// === LIBS === //
const useapi = context.get('useapi');
const lib = require('./lib/__lib__.js');
for ( const k in lib ) {
useapi.def(`core.${k}`, lib[k], { assign: true });
}
useapi.def('core.context', require('../../util/context.js').Context);
// === SERVICES === //
const services = context.get('services');
const { LogService } = require('./LogService.js');
services.registerService('log-service', LogService);
const { AlarmService } = require('./AlarmService.js');
services.registerService('alarm', AlarmService);
const { ErrorService } = require('./ErrorService.js');
services.registerService('error-service', ErrorService);
const { PagerService } = require('./PagerService.js');
services.registerService('pager', PagerService);
const { ExpectationService } = require('./ExpectationService.js');
services.registerService('expectations', ExpectationService);
const { ProcessEventService } = require('./ProcessEventService.js');
services.registerService('process-event', ProcessEventService);
const { ServerHealthService } = require('./ServerHealthService/ServerHealthService.js');
services.registerService('server-health', ServerHealthService);
const { ParameterService } = require('./ParameterService.js');
services.registerService('params', ParameterService);
const { ContextService } = require('./ContextService.js');
services.registerService('context', ContextService);
}
}
module.exports = {
Core2Module,
};
================================================
FILE: src/backend/src/modules/core/ErrorService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../services/BaseService');
/**
* **ErrorContext Class**
*
* The `ErrorContext` class is designed to encapsulate error reporting functionality within a specific logging context.
* It facilitates the reporting of errors by providing a method to log error details along with additional contextual information.
*
* @class
* @classdesc Provides a context for error reporting with specific logging details.
* @param {ErrorService} error_service - The error service instance to use for reporting errors.
* @param {object} log_context - The logging context to associate with the error reports.
*/
class ErrorContext {
constructor (error_service, log_context) {
this.error_service = error_service;
this.log_context = log_context;
}
report (location, fields) {
fields = {
...fields,
logger: this.log_context,
};
this.error_service.report(location, fields);
}
}
/**
* The ErrorService class is responsible for handling and reporting errors within the system.
* It provides methods to initialize the service, create error contexts, and report errors with detailed logging and alarm mechanisms.
* @class ErrorService
* @extends BaseService
*/
class ErrorService extends BaseService {
/**
* Initializes the ErrorService, setting up the alarm and backup logger services.
*
* @async
* @function init
* @memberof ErrorService
* @returns {Promise} A promise that resolves when the initialization is complete.
*/
async init () {
const services = this.services;
this.alarm = services.get('alarm');
this.backupLogger = services.get('log-service').create('error-service');
}
/**
* Creates an ErrorContext instance with the provided logging context.
*
* @param {*} log_context The logging context to associate with the error reports.
* @returns {ErrorContext} An ErrorContext instance.
*/
create (log_context) {
return new ErrorContext(this, log_context);
}
/**
* Reports an error with the specified location and details.
* The "location" is a string up to the callers discretion to identify
* the source of the error.
*
* @param {*} location The location where the error occurred.
* @param {*} fields The error details to report.
* @param {boolean} [alarm=true] Whether to raise an alarm for the error.
* @returns {void}
*/
report (location, { source, logger, trace, extra, message }, alarm = true) {
message = message ?? source?.message;
logger = logger ?? this.backupLogger;
logger.error(`Error @ ${location}: ${message}; ${ source?.stack}`);
if ( alarm ) {
const alarm_id = `${location}:${message}`;
this.alarm.create(alarm_id, message, {
error: source,
...extra,
});
}
}
}
module.exports = { ErrorService };
================================================
FILE: src/backend/src/modules/core/ExpectationService.js
================================================
/*
* 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 .
*/
const { v4: uuidv4 } = require('uuid');
const BaseService = require('../../services/BaseService');
/**
* @class ExpectationService
* @extends BaseService
*
* The `ExpectationService` is a specialized service designed to assist in the diagnosis and
* management of errors related to the intricate interactions among asynchronous operations.
* It facilitates tracking and reporting on expectations, enabling better fault isolation
* and resolution in systems where synchronization and timing of operations are crucial.
*
* This service inherits from the `BaseService` and provides methods for registering,
* purging, and handling expectations, making it a valuable tool for diagnosing complex
* runtime behaviors in a system.
*/
class ExpectationService extends BaseService {
static USE = {
expect: 'core.expect',
};
/**
* Constructs the ExpectationService and initializes its internal state.
* This method is intended to be called asynchronously.
* It sets up the `expectations_` array which will be used to track expectations.
*
* @async
*/
async _construct () {
this.expectations_ = [];
}
/**
* ExpectationService registers its commands at the consolidation phase because
* the '_init' method of CommandService may not have been called yet.
*/
'__on_boot.consolidation' () {
const commands = this.services.get('commands');
commands.registerCommands('expectations', [
{
id: 'pending',
description: 'lists pending expectations',
handler: async (args, log) => {
this.purgeExpectations_();
if ( this.expectations_.length < 1 ) {
log.log('there are none');
return;
}
for ( const expectation of this.expectations_ ) {
expectation.report(log);
}
},
},
]);
}
/**
* Initializes the ExpectationService, setting up interval functions and registering commands.
*
* This method sets up a periodic interval to purge expectations and registers a command
* to list pending expectations. The interval invokes `purgeExpectations_` every second.
* The command 'pending' allows users to list and log all pending expectations.
*
* @returns {Promise} A promise that resolves when initialization is complete.
*/
async _init () {
// TODO: service to track all interval functions?
/**
* Initializes the service by setting up interval functions and registering commands.
* This method sets up a periodic interval function to purge expectations and registers
* a command to list pending expectations.
*
* @returns {void}
*/
// The comment should be placed above the method at line 68
setInterval(() => {
this.purgeExpectations_();
}, 1000);
}
/**
* Purges expectations that have been met.
*
* This method iterates through the list of expectations and removes
* those that have been satisfied. Currently, this functionality is
* disabled and needs to be re-enabled.
*
* @returns {void} This method does not return anything.
*/
purgeExpectations_ () {
return;
// TODO: Re-enable this
// for ( let i=0 ; i < this.expectations_.length ; i++ ) {
// if ( this.expectations_[i].check() ) {
// this.expectations_[i] = null;
// }
// }
// this.expectations_ = this.expectations_.filter(v => v !== null);
}
/**
* Registers an expectation to be tracked by the service.
*
* @param {Object} workUnit - The work unit to track
* @param {string} checkpoint - The checkpoint to expect
* @returns {void}
*/
expect_eventually ({ workUnit, checkpoint }) {
this.expectations_.push(new this.expect.CheckpointExpectation(workUnit, checkpoint));
}
}
module.exports = {
ExpectationService,
};
================================================
FILE: src/backend/src/modules/core/LogService.js
================================================
/*
* 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 .
*/
const logSeverity = (ordinal, label, esc, winst) => ({ ordinal, label, esc, winst });
const LOG_LEVEL_ERRO = logSeverity(0, 'ERRO', '31;1', 'error');
const LOG_LEVEL_WARN = logSeverity(1, 'WARN', '33;1', 'warn');
const LOG_LEVEL_INFO = logSeverity(2, 'INFO', '36;1', 'info');
const LOG_LEVEL_NOTICEME = logSeverity(3, 'NOTICE_ME', '33;1', 'error');
const LOG_LEVEL_SYSTEM = logSeverity(3, 'SYSTEM', '36;1', 'system');
const LOG_LEVEL_DEBU = logSeverity(4, 'DEBU', '37', 'debug');
const LOG_LEVEL_TICK = logSeverity(10, 'TICK', '34;1', 'info');
const winston = require('winston');
const { Context } = require('../../util/context');
const BaseService = require('../../services/BaseService');
const { stringify_log_entry } = require('./lib/log');
require('winston-daily-rotate-file');
const WINSTON_LEVELS = {
system: 0,
error: 1,
warn: 10,
info: 20,
http: 30,
verbose: 40,
debug: 50,
silly: 60,
};
let display_log_level = process.env.DEBUG ? 100 : 3;
const display_log_level_label = {
0: 'ERRO',
1: 'WARN',
2: 'INFO',
3: 'SYSTEM',
4: 'DEBUG',
100: 'ALL',
};
/**
* Represents a logging context within the LogService.
* This class is used to manage logging operations with specific context information,
* allowing for hierarchical logging structures and dynamic field additions.
* @class LogContext
*/
class LogContext {
constructor (logService, { crumbs, fields }) {
this.logService = logService;
this.crumbs = crumbs;
this.fields = fields;
}
sub (name, fields = {}) {
return new LogContext(this.logService,
{
crumbs: name ? [...this.crumbs, name] : [...this.crumbs],
fields: { ...this.fields, ...fields },
});
}
info (message, fields, objects) {
this.log(LOG_LEVEL_INFO, message, fields, objects);
}
warn (message, fields, objects) {
this.log(LOG_LEVEL_WARN, message, fields, objects);
}
debug (message, fields, objects) {
this.log(LOG_LEVEL_DEBU, message, fields, objects);
}
error (message, fields, objects) {
this.log(LOG_LEVEL_ERRO, message, fields, objects);
}
tick (message, fields, objects) {
this.log(LOG_LEVEL_TICK, message, fields, objects);
}
called (fields = {}) {
this.log(LOG_LEVEL_DEBU, 'called', fields);
}
noticeme (message, fields, objects) {
this.log(LOG_LEVEL_NOTICEME, message, fields, objects);
}
system (message, fields, objects) {
this.log(LOG_LEVEL_SYSTEM, message, fields, objects);
}
cache (isCacheHit, identifier, fields = {}) {
this.log(LOG_LEVEL_DEBU,
isCacheHit ? 'cache_hit' : 'cache_miss',
{ identifier, ...fields });
}
log (log_level, message, fields = {}, objects = {}) {
fields = { ...this.fields, ...fields };
{
const x = Context.get(undefined, { allow_fallback: true });
if ( x && x.get('trace_request') ) {
fields.trace_request = x.get('trace_request');
}
if ( !fields.actor && x && x.get('actor') ) {
try {
fields.actor = x.get('actor');
} catch (e) {
console.log('error logging actor (this is probably fine):', e);
}
}
}
for ( const k in fields ) {
if (
fields[k] &&
typeof fields[k].toLogFields === 'function'
) fields[k] = fields[k].toLogFields();
}
if ( Context.get('injected_logger', { allow_fallback: true }) ) {
Context.get('injected_logger').log(
message + (fields ? (`; fields: ${ JSON.stringify(fields)}`) : ''));
}
this.logService.log_(log_level,
this.crumbs,
message,
fields,
objects);
}
/**
* Generates a human-readable trace ID for logging purposes.
*
* @returns {string} A trace ID in the format 'xxxxxx-xxxxxx' where each segment is a
* random string of six lowercase letters and digits.
*/
mkid () {
// generate trace id
const trace_id = [];
for ( let i = 0; i < 2; i++ ) {
trace_id.push(Math.random().toString(36).slice(2, 8));
}
return trace_id.join('-');
}
/**
* Adds a trace id to this logging context for tracking purposes.
* @returns {LogContext} The current logging context with the trace id added.
*/
traceOn () {
this.fields.trace_id = this.mkid();
return this;
}
/**
* Gets the log buffer maintained by the LogService. This shows the most
* recent log entries.
* @returns {Array} An array of log entries stored in the buffer.
*/
get_log_buffer () {
return this.logService.get_log_buffer();
}
}
/**
* Timestamp in milliseconds since the epoch, used for calculating log entry duration.
*/
/**
* @class DevLogger
* @classdesc
* A development logger class designed for logging messages during development.
* This logger can either log directly to console or delegate logging to another logger.
* It provides functionality to turn logging on/off, and can optionally write logs to a file.
*
* @param {function} log - The logging function, typically `console.log` or similar.
* @param {object} [opt_delegate] - An optional logger to which log messages can be delegated.
*/
class DevLogger {
// TODO: this should eventually delegate to winston logger
constructor (log, opt_delegate) {
this.log = log;
this.off = false;
this.recto = null;
if ( opt_delegate ) {
this.delegate = opt_delegate;
}
}
onLogMessage (log_lvl, crumbs, message, fields, objects) {
if ( this.delegate ) {
this.delegate.onLogMessage(log_lvl, crumbs, message, fields, objects);
}
if ( this.off ) return;
if ( !process.env.DEBUG && log_lvl.ordinal > display_log_level ) return;
const ld = Context.get('logdent', { allow_fallback: true });
const prefix = globalThis.dev_console_indent_on
? Array(ld ?? 0).fill(' ').join('')
: '';
this.log_(stringify_log_entry({
prefix,
log_lvl,
crumbs,
message,
fields,
objects,
}));
}
log_ (text) {
if ( this.recto ) {
const fs = require('node:fs');
fs.appendFileSync(this.recto, `${text }\n`);
}
this.log(text);
}
}
/**
* @class NullLogger
* @description A logger that does nothing, effectively disabling logging.
* This class is used when logging is not desired or during development
* to avoid performance overhead or for testing purposes.
*/
class NullLogger {
// TODO: this should eventually delegate to winston logger
constructor (log, opt_delegate) {
this.log = log;
if ( opt_delegate ) {
this.delegate = opt_delegate;
}
}
onLogMessage () {
}
}
/**
* WinstonLogger Class
*
* A logger that delegates log messages to a Winston logger instance.
*/
class WinstonLogger {
constructor (winst) {
this.winst = winst;
}
onLogMessage (log_lvl, crumbs, message, fields) {
this.winst.log({
...fields,
label: crumbs.join('.'),
level: log_lvl.winst,
message,
});
}
}
/**
* @class TimestampLogger
* @classdesc A logger that adds timestamps to log messages before delegating them to another logger.
* This class wraps another logger instance to ensure that all log messages include a timestamp,
* which can be useful for tracking the sequence of events in a system.
*
* @param {Object} delegate - The logger instance to which the timestamped log messages are forwarded.
*/
class TimestampLogger {
constructor (delegate) {
this.delegate = delegate;
}
onLogMessage (log_lvl, crumbs, message, fields, ...a) {
fields = { ...fields, timestamp: new Date() };
this.delegate.onLogMessage(log_lvl, crumbs, message, fields, ...a);
}
}
/**
* The `BufferLogger` class extends the logging functionality by maintaining a buffer of log entries.
* This class is designed to:
* - Store a specified number of recent log messages.
* - Allow for retrieval of these logs for debugging or monitoring purposes.
* - Ensure that the log buffer does not exceed the defined size by removing older entries when necessary.
* - Delegate logging messages to another logger while managing its own buffer.
*/
class BufferLogger {
constructor (size, delegate) {
this.size = size;
this.delegate = delegate;
this.buffer = [];
}
onLogMessage (log_lvl, crumbs, message, fields, ...a) {
this.buffer.push({ log_lvl, crumbs, message, fields, ...a });
if ( this.buffer.length > this.size ) {
this.buffer.shift();
}
this.delegate.onLogMessage(log_lvl, crumbs, message, fields, ...a);
}
}
/**
* Represents a custom logger that can modify log messages before they are passed to another logger.
* @class CustomLogger
* @extends {Object}
* @param {Object} delegate - The delegate logger to which modified log messages will be passed.
* @param {Function} callback - A callback function that modifies log parameters before delegation.
*/
class CustomLogger {
constructor (delegate, callback) {
this.delegate = delegate;
this.callback = callback;
}
async onLogMessage (log_lvl, crumbs, message, fields, ...a) {
// Logging is allowed to be performed without a context, but we
// don't want log functions to be asynchronous which rules out
// wrapping with Context.allow_fallback. Instead we provide a
// context as a parameter.
const context = Context.get(undefined, { allow_fallback: true });
let ret;
try {
ret = await this.callback({
context,
log_lvl,
crumbs,
message,
fields,
args: a,
});
} catch (e) {
console.error(e);
}
if ( ret && ret.skip ) return;
if ( ! ret ) {
this.delegate.onLogMessage(log_lvl,
crumbs,
message,
fields,
...a);
return;
}
const {
log_lvl: _log_lvl,
crumbs: _crumbs,
message: _message,
fields: _fields,
args,
} = ret;
this.delegate.onLogMessage(_log_lvl ?? log_lvl,
_crumbs ?? crumbs,
_message ?? message,
_fields ?? fields,
...(args ?? a ?? []));
}
}
/**
* The `LogService` class extends `BaseService` and is responsible for managing and
* orchestrating various logging functionalities within the application. It handles
* log initialization, middleware registration, log directory management, and
* provides methods for creating log contexts and managing log output levels.
*/
class LogService extends BaseService {
static MODULES = {
path: require('path'),
};
/**
* Defines the modules required by the LogService class.
* This static property contains modules that are used for file path operations.
* @property {Object} MODULES - An object containing required modules.
* @property {Object} MODULES.path - The Node.js path module for handling and resolving file paths.
*/
async _construct () {
this.loggers = [];
this.bufferLogger = null;
}
/**
* Registers a custom logging middleware with the LogService.
* @param {*} callback - The callback function that modifies log parameters before delegation.
*/
register_log_middleware (callback) {
this.loggers[0] = new CustomLogger(this.loggers[0], callback);
}
/**
* Registers logging commands with the command service.
*/
'__on_boot.consolidation' () {
const commands = this.services.get('commands');
commands.registerCommands('logs', [
{
id: 'show',
description: 'toggle log output',
handler: async () => {
this.devlogger && (this.devlogger.off = !this.devlogger.off);
},
},
{
id: 'rec',
description: 'start recording to a file via dev logger',
handler: async (args, ctx) => {
const [name] = args;
const { log } = ctx;
if ( ! this.devlogger ) {
log('no dev logger; what are you doing?');
}
this.devlogger.recto = name;
},
},
{
id: 'stop',
description: 'stop recording to a file via dev logger',
handler: async ([_name], log) => {
if ( ! this.devlogger ) {
log('no dev logger; what are you doing?');
}
this.devlogger.recto = null;
},
},
{
id: 'indent',
description: 'toggle log indentation',
handler: async () => {
globalThis.dev_console_indent_on =
!globalThis.dev_console_indent_on;
},
},
{
id: 'get-level',
description: 'get the current log level for displayed logs',
handler: async (args, log) => {
log.log(`${display_log_level} (${display_log_level_label[display_log_level] ?? '?'})`);
},
},
{
id: 'set-level',
description: 'set the new log level for displayed logs',
handler: async (args, log) => {
display_log_level = Number(args[0]);
log.log(`${display_log_level} (${display_log_level_label[display_log_level] ?? '?'})`);
},
},
]);
}
/**
* Registers logging commands with the command service.
*
* This method sets up various logging commands that can be used to
* interact with the log output, such as toggling log display,
* starting/stopping log recording, and toggling log indentation.
*
* @memberof LogService
*/
async _init () {
const config = this.global_config;
this.ensure_log_directory_();
let logger;
if ( ! config.no_winston ) {
const requested_level = config.logger?.level;
const winston_level = typeof requested_level === 'string'
? requested_level.toLowerCase()
: undefined;
const transports = config.toConsole
? [
new winston.transports.Console({
level: winston_level ?? 'http',
}),
]
: [
new winston.transports.DailyRotateFile({
level: 'http',
filename: `${this.log_directory}/%DATE%.log`,
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '2d',
}),
new winston.transports.DailyRotateFile({
level: 'error',
filename: `${this.log_directory}/error-%DATE%.log`,
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '2d',
}),
new winston.transports.DailyRotateFile({
level: 'system',
filename: `${this.log_directory}/system-%DATE%.log`,
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '2d',
}),
];
logger = new WinstonLogger(winston.createLogger({
levels: WINSTON_LEVELS,
transports,
}));
}
if ( config.env === 'dev' ) {
logger = config.flag_no_logs // useful for profiling
? new NullLogger()
: new DevLogger(console.log.bind(console), logger);
this.devlogger = logger;
}
logger = new TimestampLogger(logger);
logger = new BufferLogger(config.log_buffer_size ?? 20, logger);
this.bufferLogger = logger;
this.loggers.push(logger);
this.output_lvl = LOG_LEVEL_INFO;
if ( config.logger ) {
// config.logger.level is a string, e.g. 'debug'
// first we find the appropriate log level
const output_lvl = Object.values({
LOG_LEVEL_ERRO,
LOG_LEVEL_WARN,
LOG_LEVEL_INFO,
LOG_LEVEL_DEBU,
LOG_LEVEL_TICK,
}).find(lvl => {
return lvl.label === config.logger.level.toUpperCase() ||
lvl.winst === config.logger.level.toLowerCase() ||
lvl.ordinal === config.logger.level;
});
// then we set the output level to the ordinal of that level
this.output_lvl = output_lvl.ordinal;
}
this.log = this.create('log-service');
this.log.system('log service started');
this.log.debug('log service configuration', {
output_lvl: this.output_lvl,
log_directory: this.log_directory,
});
this.services.logger = this.create('services-container');
globalThis.root_context.set('logger', this.create('root-context'));
{
const util = require('util');
const logger = this.create('console');
if ( ! globalThis.original_console_object ) {
globalThis.original_console_object = console;
}
// Keep console prototype
const logconsole = Object.create(console);
// Override simple log functions
const logfn = level => (...a) => {
logger[level](a.map(arg => {
if ( typeof arg === 'string' ) return arg;
return util.inspect(arg, undefined, undefined, true);
}).join(' '));
};
logconsole.log = logfn('info');
logconsole.info = logfn('info');
logconsole.warn = logfn('warn');
logconsole.error = logfn('error');
logconsole.debug = logfn('debug');
globalThis.console = logconsole;
}
}
/**
* Create a new log context with the specified prefix
*
* @param {1} prefix - The prefix for the log context
* @param {*} fields - Optional fields to include in the log context
* @returns {LogContext} A new log context with the specified prefix and fields
*/
create (prefix, fields = {}) {
const logContext = new LogContext(this,
{
crumbs: [prefix],
fields,
});
return logContext;
}
log_ (log_lvl, crumbs, message, fields, objects) {
try {
// skip messages that are above the output level
if ( log_lvl.ordinal > this.output_lvl ) return;
if ( this.config.trace_logs ) {
fields.stack = (new Error('logstack')).stack;
}
for ( const logger of this.loggers ) {
logger.onLogMessage(log_lvl, crumbs, message, fields, objects);
}
} catch (e) {
// If logging fails, we don't want anything to happen
// that might trigger a log message. This causes an
// infinite loop and I learned that the hard way.
console.error('Logging failed', e);
// TODO: trigger an alarm either in a non-logging
// context (prereq: per-context service overrides)
// or with a cooldown window (prereq: cooldowns in AlarmService)
}
}
/**
* Ensures that a log directory exists for logging purposes.
* This method attempts to create or locate a directory for log files,
* falling back through several predefined paths if the preferred
* directory does not exist or cannot be created.
*
* @throws {Error} If no suitable log directory can be found or created.
*/
ensure_log_directory_ () {
// STEP 1: Try /var/puter/logs/heyputer
{
const fs = require('fs');
const path = '/var/puter/logs/heyputer';
// Making this directory if it doesn't exist causes issues
// for users running with development instructions
if ( ! fs.existsSync('/var/puter') ) {
return;
}
try {
fs.mkdirSync(path, { recursive: true });
this.log_directory = path;
return;
} catch (e) {
// ignore
}
}
// STEP 2: Try /tmp/heyputer
{
const fs = require('fs');
const path = '/tmp/heyputer';
try {
fs.mkdirSync(path, { recursive: true });
this.log_directory = path;
return;
} catch (e) {
// ignore
}
}
// STEP 3: Try working directory
{
const fs = require('fs');
const path = './heyputer';
try {
fs.mkdirSync(path, { recursive: true });
this.log_directory = path;
return;
} catch (e) {
// ignore
}
}
// STEP 4: Give up
throw new Error('Unable to create or find log directory');
}
/**
* Generates a sanitized file path for log files.
*
* @param {string} name - The name of the log file, which will be sanitized to remove any path characters.
* @returns {string} A sanitized file path within the log directory.
*/
get_log_file (name) {
// sanitize name: cannot contain path characters
name = name.replace(/[^a-zA-Z0-9-_]/g, '_');
return this.modules.path.join(this.log_directory, name);
}
/**
* Get the most recent log entries from the buffer maintained by the LogService.
* By default, the buffer contains the last 20 log entries.
* @returns
*/
get_log_buffer () {
return this.bufferLogger.buffer;
}
}
module.exports = {
LogService,
stringify_log_entry,
};
================================================
FILE: src/backend/src/modules/core/PagerService.js
================================================
/*
* 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 .
*/
const pdjs = require('@pagerduty/pdjs');
const BaseService = require('../../services/BaseService');
const util = require('util');
/**
* @class PagerService
* @extends BaseService
* @description The PagerService class is responsible for handling pager alerts.
* It extends the BaseService class and provides methods for constructing,
* initializing, and managing alert handlers. The class interacts with PagerDuty
* through the pdjs library to send alerts and integrates with other services via
* command registration.
*/
class PagerService extends BaseService {
static USE = {
Context: 'core.context',
};
async _construct () {
this.config = this.global_config.pager;
this.alertHandlers_ = [];
}
/**
* PagerService registers its commands at the consolidation phase because
* the '_init' method of CommandService may not have been called yet.
*/
'__on_boot.consolidation' () {
this._register_commands(this.services.get('commands'));
}
/**
* Initializes the PagerService instance by setting the configuration and
* initializing an empty alert handler array.
*
* @async
* @memberOf PagerService
* @returns {Promise}
*/
async _init () {
this.alertHandlers_ = [];
if ( ! this.config ) {
return;
}
this.onInit();
}
/**
* Initializes PagerDuty configuration and registers alert handlers.
* If PagerDuty is enabled in the configuration, it sets up an alert handler
* to send alerts to PagerDuty.
*
* @method onInit
*/
onInit () {
if ( this.config.pagerduty && this.config.pagerduty.enabled ) {
this.alertHandlers_.push(async alert => {
const event = pdjs.event;
const fields_clean = {};
for ( const [key, value] of Object.entries(alert?.fields ?? {}) ) {
fields_clean[key] = util.inspect(value);
}
const custom_details = {
...(alert.custom || {}),
server_id: this.global_config.server_id,
};
const ctx = this.Context.get(undefined, { allow_fallback: true });
// Add request payload if any exists
const req = ctx.get('req');
if ( req ) {
if ( req.body ) {
// Remove fields which may contain sensitive information
delete req.body.password;
delete req.body.email;
// Add the request body to the custom details
custom_details.request_body = req.body;
}
}
this.log.info('it is sending to PD');
await event({
data: {
routing_key: this.config.pagerduty.routing_key,
event_action: 'trigger',
dedup_key: alert.id,
payload: {
summary: alert.message,
source: alert.source,
severity: alert.severity,
custom_details,
},
},
});
});
}
}
/**
* Sends an alert to all registered alert handlers.
*
* This method iterates through all alert handlers and attempts to send the alert.
* If any handler fails to send the alert, an error message is logged.
*
* @param {Object} alert - The alert object containing details about the alert.
*/
async alert (alert) {
for ( const handler of this.alertHandlers_ ) {
try {
await handler(alert);
} catch (e) {
this.log.error(`failed to send pager alert: ${e?.message}`);
}
}
}
_register_commands (commands) {
commands.registerCommands('pager', [
{
id: 'test-alert',
description: 'create a test alert',
handler: async (args, log) => {
const [severity] = args;
await this.alert({
id: 'test-alert',
message: 'test alert',
source: 'test',
severity,
});
},
},
]);
}
}
module.exports = {
PagerService,
};
================================================
FILE: src/backend/src/modules/core/ParameterService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../services/BaseService');
/**
* @class Parameter
* @description Represents a configurable parameter with value management, constraints, and change notification capabilities.
* Provides functionality for setting/getting values, binding to object instances, and subscribing to value changes.
* Supports validation through configurable constraints and maintains a list of value change listeners.
*/
class Parameter {
constructor (spec) {
this.spec_ = spec;
this.valueListeners_ = [];
if ( spec.default ) {
this.value_ = spec.default;
}
}
/**
* Sets a new value for the parameter after validating against constraints
* @param {*} value - The new value to set for the parameter
* @throws {Error} If the value fails any constraint checks
* @fires valueListeners with new value and old value
* @async
*/
async set (value) {
for ( const constraint of (this.spec_.constraints ?? []) ) {
if ( ! await constraint.check(value) ) {
throw new Error(`value ${value} does not satisfy constraint ${constraint.id}`);
}
}
const old = this.value_;
this.value_ = value;
for ( const listener of this.valueListeners_ ) {
listener(value, { old });
}
}
/**
* Gets the current value of this parameter
* @returns {Promise<*>} The parameter's current value
*/
async get () {
return this.value_;
}
bindToInstance (instance, name) {
const value = this.value_;
instance[name] = value;
this.valueListeners_.push((value) => {
instance[name] = value;
});
}
subscribe (listener) {
this.valueListeners_.push(listener);
}
}
/**
* @class ParameterService
* @extends BaseService
* @description Service class for managing system parameters and their values.
* Provides functionality for creating, getting, setting, and subscribing to parameters.
* Supports parameter binding to instances and includes command registration for parameter management.
* Parameters can have constraints, default values, and change listeners.
*/
class ParameterService extends BaseService {
_construct () {
/** @type {Array} */
this.parameters_ = [];
}
/**
* Initializes the service by registering commands with the command service.
* This method is called during service startup to set up command handlers
* for parameter management.
* @private
*/
'__on_boot.consolidation' () {
this._registerCommands(this.services.get('commands'));
}
createParameters (serviceName, parameters, opt_instance) {
for ( const parameter of parameters ) {
this.log.debug(`registering parameter ${serviceName}:${parameter.id}`);
this.parameters_.push(new Parameter({
...parameter,
id: `${serviceName}:${parameter.id}`,
}));
if ( opt_instance ) {
this.bindToInstance(`${serviceName}:${parameter.id}`,
opt_instance,
parameter.id);
}
}
}
/**
* Gets the value of a parameter by its ID
* @param {string} id - The unique identifier of the parameter to retrieve
* @returns {Promise<*>} The current value of the parameter
* @throws {Error} If parameter with given ID is not found
*/
async get (id) {
const parameter = this._get_param(id);
return await parameter.get();
}
bindToInstance (id, instance, name) {
const parameter = this._get_param(id);
return parameter.bindToInstance(instance, name);
}
subscribe (id, listener) {
const parameter = this._get_param(id);
return parameter.subscribe(listener);
}
_get_param (id) {
const parameter = this.parameters_.find(p => p.spec_.id === id);
if ( ! parameter ) {
throw new Error(`unknown parameter: ${id}`);
}
return parameter;
}
/**
* Registers parameter-related commands with the command service
* @param {Object} commands - The command service instance to register with
*/
_registerCommands (commands) {
const completeParameterName = (args) => {
// The parameter name is the first argument, so return no results if we're on the second or later.
if ( args.length > 1 )
{
return;
}
const lastArg = args[args.length - 1];
return this.parameters_
.map(parameter => parameter.spec_.id)
.filter(parameterName => parameterName.startsWith(lastArg));
};
commands.registerCommands('params', [
{
id: 'get',
description: 'get a parameter',
handler: async (args, log) => {
const [name] = args;
const value = await this.get(name);
log.log(value);
},
completer: completeParameterName,
},
{
id: 'set',
description: 'set a parameter',
handler: async (args, log) => {
const [name, value] = args;
const parameter = this._get_param(name);
parameter.set(value);
log.log(value);
},
completer: completeParameterName,
},
{
id: 'list',
description: 'list parameters',
handler: async (args, log) => {
const [prefix] = args;
let parameters = this.parameters_;
if ( prefix ) {
parameters = parameters
.filter(p => p.spec_.id.startsWith(prefix));
}
log.log(`available parameters${
prefix ? ` (starting with: ${prefix})` : ''
}:`);
for ( const parameter of parameters ) {
// log.log(`- ${parameter.spec_.id}: ${parameter.spec_.description}`);
// Log parameter description and value
const value = await parameter.get();
log.log(`- ${parameter.spec_.id} = ${value}`);
log.log(` ${parameter.spec_.description}`);
}
},
},
]);
}
}
module.exports = {
ParameterService,
};
================================================
FILE: src/backend/src/modules/core/ProcessEventService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../services/BaseService');
/**
* Service class that handles process-wide events and errors.
* Provides centralized error handling for uncaught exceptions and unhandled promise rejections.
* Sets up event listeners on the process object to capture and report critical errors
* through the logging and error reporting services.
*
* @class ProcessEventService
*/
class ProcessEventService extends BaseService {
static USE = {
Context: 'core.context',
};
_init () {
const services = this.services;
const log = services.get('log-service').create('process-event-service');
const errors = services.get('error-service').create(log);
process.on('uncaughtException', async (err, origin) => {
/**
* Handles uncaught exceptions in the process
* Sets up an event listener that reports errors when uncaught exceptions occur
* @param {Error} err - The uncaught exception error object
* @param {string} origin - The origin of the uncaught exception
* @returns {Promise}
*/
await this.Context.allow_fallback(async () => {
errors.report('process:uncaughtException', {
source: err,
origin,
trace: true,
alarm: true,
});
});
});
process.on('unhandledRejection', async (reason, promise) => {
/**
* Handles unhandled promise rejections by reporting them to the error service
* @param {*} reason - The rejection reason/error
* @param {Promise} promise - The rejected promise
* @returns {Promise} Resolves when error is reported
*/
await this.Context.allow_fallback(async () => {
errors.report('process:unhandledRejection', {
source: reason,
promise,
trace: true,
alarm: true,
});
});
});
}
}
module.exports = {
ProcessEventService,
};
================================================
FILE: src/backend/src/modules/core/README.md
================================================
# Core2Module
A replacement for CoreModule with as few external relative requires as possible.
This will eventually be the successor to CoreModule, the main module for Puter's backend.
## Services
### AlarmService
AlarmService class is responsible for managing alarms.
It provides methods for creating, clearing, and handling alarms.
#### Listeners
##### `boot.consolidation`
AlarmService registers its commands at the consolidation phase because
the '_init' method of CommandService may not have been called yet.
#### Methods
##### `create`
Method to create an alarm with the given ID, message, and fields.
If the ID already exists, it will be updated with the new fields
and the occurrence count will be incremented.
###### Parameters
- **id:** Unique identifier for the alarm.
- **message:** Message associated with the alarm.
- **fields:** Additional information about the alarm.
##### `clear`
Method to clear an alarm with the given ID.
###### Parameters
- **id:** The ID of the alarm to clear.
##### `get_alarm`
Method to get an alarm by its ID.
###### Parameters
- **id:** The ID of the alarm to get.
### ErrorService
The ErrorService class is responsible for handling and reporting errors within the system.
It provides methods to initialize the service, create error contexts, and report errors with detailed logging and alarm mechanisms.
#### Methods
##### `init`
Initializes the ErrorService, setting up the alarm and backup logger services.
##### `create`
Creates an ErrorContext instance with the provided logging context.
###### Parameters
- **log_context:** The logging context to associate with the error reports.
##### `report`
Reports an error with the specified location and details.
The "location" is a string up to the callers discretion to identify
the source of the error.
###### Parameters
- **location:** The location where the error occurred.
- **fields:** The error details to report.
### ExpectationService
#### Listeners
##### `boot.consolidation`
ExpectationService registers its commands at the consolidation phase because
the '_init' method of CommandService may not have been called yet.
#### Methods
##### `expect_eventually`
Registers an expectation to be tracked by the service.
###### Parameters
- **workUnit:** The work unit to track
- **checkpoint:** The checkpoint to expect
### LogService
The `LogService` class extends `BaseService` and is responsible for managing and
orchestrating various logging functionalities within the application. It handles
log initialization, middleware registration, log directory management, and
provides methods for creating log contexts and managing log output levels.
#### Listeners
##### `boot.consolidation`
Registers logging commands with the command service.
#### Methods
##### `register_log_middleware`
Registers a custom logging middleware with the LogService.
###### Parameters
- **callback:** The callback function that modifies log parameters before delegation.
##### `create`
Create a new log context with the specified prefix
###### Parameters
- **prefix:** The prefix for the log context
- **fields:** Optional fields to include in the log context
##### `get_log_file`
Generates a sanitized file path for log files.
###### Parameters
- **name:** The name of the log file, which will be sanitized to remove any path characters.
##### `get_log_buffer`
Get the most recent log entries from the buffer maintained by the LogService.
By default, the buffer contains the last 20 log entries.
### PagerService
#### Listeners
##### `boot.consolidation`
PagerService registers its commands at the consolidation phase because
the '_init' method of CommandService may not have been called yet.
#### Methods
##### `onInit`
Initializes PagerDuty configuration and registers alert handlers.
If PagerDuty is enabled in the configuration, it sets up an alert handler
to send alerts to PagerDuty.
##### `alert`
Sends an alert to all registered alert handlers.
This method iterates through all alert handlers and attempts to send the alert.
If any handler fails to send the alert, an error message is logged.
###### Parameters
- **alert:** The alert object containing details about the alert.
### ProcessEventService
Service class that handles process-wide events and errors.
Provides centralized error handling for uncaught exceptions and unhandled promise rejections.
Sets up event listeners on the process object to capture and report critical errors
through the logging and error reporting services.
## Libraries
### core.expect
### core.util.identutil
#### Functions
##### `randomItem`
Select a random item from an array using a random number generator function.
###### Parameters
- **arr:** The array to select an item from
### core.util.logutil
#### Functions
##### `stringify_log_entry`
Stringifies a log entry into a formatted string for console output.
###### Parameters
- **logEntry:** The log entry object containing:
### stdio
#### Functions
##### `visible_length`
METADATA // {"ai-commented":{"service":"claude"}}
##### `split_lines`
Split a string into lines according to the terminal width,
preserving ANSI escape sequences, and return an array of lines.
###### Parameters
- **str:** The string to split into lines
### core.util.strutil
#### Functions
##### `quot`
METADATA // {"def":"core.util.strutil","ai-params":{"service":"claude"},"ai-commented":{"service":"claude"}}
## Notes
### Outside Imports
This module has external relative imports. When these are
removed it may become possible to move this module to an
extension.
**Imports:**
- `../../services/BaseService.js`
- `../../util/context.js`
- `../../services/BaseService` (use.BaseService)
- `../../services/BaseService` (use.BaseService)
- `../../util/context`
- `../../services/BaseService` (use.BaseService)
- `../../services/BaseService` (use.BaseService)
- `../../services/BaseService` (use.BaseService)
================================================
FILE: src/backend/src/modules/core/ServerHealthService/ServerHealthRedisCacheKeys.js
================================================
/*
* 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 .
*/
export const ServerHealthRedisCacheKeys = {
status: 'server-health:status',
};
================================================
FILE: src/backend/src/modules/core/ServerHealthService/ServerHealthService.js
================================================
/*
* 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 .
*/
const { redisClient } = require('../../../clients/redis/redisSingleton');
const { setRedisCacheValue } = require('../../../clients/redis/cacheUpdate.js');
const { ServerHealthRedisCacheKeys } = require('./ServerHealthRedisCacheKeys.js');
const BaseService = require('../../../services/BaseService');
const { promise } = require('@heyputer/putility').libs;
const SECOND = 1000;
/**
* The ServerHealthService class provides comprehensive health monitoring for the server.
* It extends the BaseService class to include functionality for:
* - Periodic system checks (e.g., RAM usage, service checks)
* - Managing health check results and failures
* - Triggering alarms for critical conditions
* - Logging and managing statistics for health metrics
*
* This service is designed to work primarily on Linux systems, reading system metrics
* from `/proc/meminfo` and handling alarms via an external 'alarm' service.
*/
class ServerHealthService extends BaseService {
static USE = {
linuxutil: 'core.util.linuxutil',
};
/**
* Defines the modules used by ServerHealthService.
* This static property is used to initialize and access system modules required for health checks.
* @type {Object}
* @property {fs} fs - The file system module for reading system information.
*/
static MODULES = {
fs: require('fs'),
};
/**
* Initializes the internal checks and failure tracking for the service.
* This method sets up empty arrays to store health checks and their failure statuses.
*
* @private
*/
_construct () {
this.checks_ = [];
this.failures_ = [];
}
async _init () {
this.init_service_checks_();
/*
There's an interesting thread here:
https://github.com/nodejs/node/issues/23892
It's a discussion about whether to report "free" or "available" memory
in `os.freemem()`. There was no clear consensus in the discussion,
and then libuv was changed to report "available" memory instead.
I've elected not to use `os.freemem()` here and instead read
`/proc/meminfo` directly.
*/
const min_available_KiB = 1024 * 1024 * 2; // 2 GiB
const svc_alarm = this.services.get('alarm');
this.stats_ = {};
// Disable if we're not on Linux
if ( process.platform !== 'linux' ) {
return;
}
if ( this.config.no_system_checks ) return;
/**
* Adds a health check to the service.
*
* @param {string} name - The name of the health check.
* @param {Function} fn - The function to execute for the health check.
* @returns {Object} A chainable object to add failure handlers.
*/
this.add_check('ram-usage', async () => {
const meminfo_text = await this.modules.fs.promises.readFile('/proc/meminfo', 'utf8');
const meminfo = this.linuxutil.parse_meminfo(meminfo_text);
const log_fields = {
mem_free: meminfo.MemFree,
mem_available: meminfo.MemAvailable,
mem_total: meminfo.MemTotal,
};
this.log.debug('memory', log_fields);
Object.assign(this.stats_, log_fields);
if ( meminfo.MemAvailable < min_available_KiB ) {
svc_alarm.create('low-available-memory', 'Low available memory', log_fields);
}
});
}
/**
* Initializes service health checks by setting up periodic checks.
* This method configures an interval-based execution of health checks,
* handles timeouts, and manages failure states.
*
* @param {none} - This method does not take any parameters.
* @returns {void} - This method does not return any value.
*/
init_service_checks_ () {
const svc_alarm = this.services.get('alarm');
/**
* Initializes periodic health checks for the server.
*
* This method sets up an interval to run all registered health checks
* at a specified frequency. It manages the execution of checks, handles
* timeouts, and logs errors or triggers alarms when checks fail.
*
* @private
* @method init_service_checks_
* @memberof ServerHealthService
* @param {none} - No parameters are passed to this method.
* @returns {void}
*/
promise.asyncSafeSetInterval(async () => {
this.log.tick('service checks');
const check_failures = [];
for ( const { name, fn, chainable } of this.checks_ ) {
const p_timeout = new promise.TeePromise();
/**
* Creates a TeePromise to handle potential timeouts during health checks.
*
* @returns {Promise} A promise that can be resolved or rejected from multiple places.
*/
const timeout = setTimeout(() => {
p_timeout.reject(new Error('Health check timed out'));
}, 5 * SECOND);
try {
await Promise.race([
fn(),
p_timeout,
]);
clearTimeout(timeout);
} catch ( err ) {
// Trigger an alarm if this check isn't already in the failure list
if ( this.failures_.some(v => v.name === name) ) {
return;
}
svc_alarm.create(
'health-check-failure',
`Health check ${name} failed`,
{ error: err },
);
check_failures.push({ name });
this.log.error(`Error for healthcheck fail on ${name}: ${ err.stack}`);
// Run the on_fail handlers
for ( const fn of chainable.on_fail_ ) {
try {
await fn(err);
} catch ( e ) {
this.log.error(`Error in on_fail handler for ${name}`, e);
}
}
}
}
this.failures_ = check_failures;
}, 10 * SECOND, null, {
onBehindSchedule: (drift) => {
svc_alarm.create(
'health-checks-behind-schedule',
'Health checks are behind schedule',
{ drift },
);
},
});
}
/**
* Retrieves the current server health statistics.
*
* @returns {Object} An object containing the current health statistics.
* This method returns a shallow copy of the internal `stats_` object to prevent
* direct manipulation of the service's data.
*/
async get_stats () {
return { ...this.stats_ };
}
add_check (name, fn) {
const chainable = {
on_fail_: [],
on_fail: (fn) => {
chainable.on_fail_.push(fn);
return chainable;
},
};
this.checks_.push({ name, fn, chainable });
return chainable;
}
/**
* Retrieves the current health status of the server.
* Results are cached for 30 seconds to reduce computation overhead.
*
* @returns {Object} An object containing:
* - `ok` {boolean}: Indicates if all health checks passed.
* - `failed` {Array}: An array of names of failed health checks, if any.
*/
async get_status () {
const cacheKey = ServerHealthRedisCacheKeys.status;
// Check cache first
const cached = await redisClient.get(cacheKey);
if ( cached ) {
try {
return JSON.parse(cached);
} catch (e) {
// no op cache is in an invalid state
}
}
// Compute status
const failures = this.failures_.map(v => v.name);
const status = {
ok: failures.length === 0,
...(failures.length ? { failed: failures } : {}),
};
// Cache with 5 second TTL
await setRedisCacheValue(cacheKey, JSON.stringify(status), {
ttlSeconds: 5,
eventData: status,
});
return status;
}
}
module.exports = { ServerHealthService };
================================================
FILE: src/backend/src/modules/core/lib/__lib__.js
================================================
/*
* 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 .
*/
module.exports = {
util: {
logutil: require('./log.js'),
identutil: require('./identifier.js'),
stdioutil: require('./stdio.js'),
linuxutil: require('./linux.js'),
},
expect: require('./expect.js'),
};
================================================
FILE: src/backend/src/modules/core/lib/expect.js
================================================
/*
* 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 .
*/
// METADATA // {"def":"core.expect"}
const { v4: uuidv4 } = require('uuid');
const global_config = require('../../../config');
/**
* @class WorkUnit
* @description The WorkUnit class represents a unit of work that can be tracked and monitored for checkpoints.
* It includes methods to create instances, set checkpoints, and manage the state of the work unit.
*/
class WorkUnit {
/**
* Represents a unit of work with checkpointing capabilities.
*
* @class
*/
/**
* Creates and returns a new instance of WorkUnit.
*
* @static
* @returns {WorkUnit} A new instance of WorkUnit.
*/
static create () {
return new WorkUnit();
}
/**
* Creates a new instance of the WorkUnit class.
* @static
* @returns {WorkUnit} A new WorkUnit instance.
*/
constructor () {
this.id = uuidv4();
this.checkpoint_ = null;
}
checkpoint (label) {
if ( (global_config.logging ?? [] ).includes('checkpoint') ) {
console.log('CHECKPOINT', label);
}
this.checkpoint_ = label;
}
}
/**
* @class CheckpointExpectation
* @classdesc The CheckpointExpectation class is used to represent an expectation that a specific checkpoint
* will be reached during the execution of a work unit. It includes methods to check if the checkpoint has
* been reached and to report the results of this check.
*/
class CheckpointExpectation {
constructor (workUnit, checkpoint) {
this.workUnit = workUnit;
this.checkpoint = checkpoint;
}
/**
* Constructor for CheckpointExpectation class.
* Initializes the instance with a WorkUnit and a checkpoint label.
* @param {WorkUnit} workUnit - The work unit associated with the checkpoint.
* @param {string} checkpoint - The checkpoint label to be checked.
*/
check () {
// TODO: should be true if checkpoint was ever reached
return this.workUnit.checkpoint_ == this.checkpoint;
}
report (log) {
if ( this.check() ) return;
log.log(`operation(${this.workUnit.id}): ` +
`expected ${JSON.stringify(this.checkpoint)} ` +
`and got ${JSON.stringify(this.workUnit.checkpoint_)}.`);
}
}
module.exports = {
WorkUnit,
CheckpointExpectation,
};
================================================
FILE: src/backend/src/modules/core/lib/identifier.js
================================================
/*
* 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 .
*/
const adjectives = [
'amazing', 'ambitious', 'articulate', 'cool', 'bubbly', 'mindful', 'noble', 'savvy', 'serene',
'sincere', 'sleek', 'sparkling', 'spectacular', 'splendid', 'spotless', 'stunning',
'awesome', 'beaming', 'bold', 'brilliant', 'cheerful', 'modest', 'motivated',
'friendly', 'fun', 'funny', 'generous', 'gifted', 'graceful', 'grateful',
'passionate', 'patient', 'peaceful', 'perceptive', 'persistent',
'helpful', 'sensible', 'loyal', 'honest', 'clever', 'capable',
'calm', 'smart', 'genius', 'bright', 'charming', 'creative', 'diligent', 'elegant', 'fancy',
'colorful', 'avid', 'active', 'gentle', 'happy', 'intelligent',
'jolly', 'kind', 'lively', 'merry', 'nice', 'optimistic', 'polite',
'quiet', 'relaxed', 'silly', 'witty', 'young',
'strong', 'brave', 'agile', 'bold', 'confident', 'daring',
'fearless', 'heroic', 'mighty', 'powerful', 'valiant', 'wise', 'wonderful', 'zealous',
'warm', 'swift', 'neat', 'tidy', 'nifty', 'lucky', 'keen',
'blue', 'red', 'aqua', 'green', 'orange', 'pink', 'purple', 'cyan', 'magenta', 'lime',
'teal', 'lavender', 'beige', 'maroon', 'navy', 'olive', 'silver', 'gold', 'ivory',
];
const nouns = [
'street', 'roof', 'floor', 'tv', 'idea', 'morning', 'game', 'wheel', 'bag', 'clock', 'pencil', 'pen',
'magnet', 'chair', 'table', 'house', 'room', 'book', 'car', 'tree', 'candle', 'light', 'planet',
'flower', 'bird', 'fish', 'sun', 'moon', 'star', 'cloud', 'rain', 'snow', 'wind', 'mountain',
'river', 'lake', 'sea', 'ocean', 'island', 'bridge', 'road', 'train', 'plane', 'ship', 'bicycle',
'circle', 'square', 'garden', 'harp', 'grass', 'forest', 'rock', 'cake', 'pie', 'cookie', 'candy',
'butterfly', 'computer', 'phone', 'keyboard', 'mouse', 'cup', 'plate', 'glass', 'door',
'window', 'key', 'wallet', 'pillow', 'bed', 'blanket', 'soap', 'towel', 'lamp', 'mirror',
'camera', 'hat', 'shirt', 'pants', 'shoes', 'watch', 'ring',
'necklace', 'ball', 'toy', 'doll', 'kite', 'balloon', 'guitar', 'violin', 'piano', 'drum',
'trumpet', 'flute', 'viola', 'cello', 'harp', 'banjo', 'tuba',
];
const words = {
adjectives,
nouns,
};
/**
* Select a random item from an array using a random number generator function.
*
* @param {Array} arr - The array to select an item from
* @param {function} [random=Math.random] - Random number generator function
* @returns {T} A random item from the array
*/
const randomItem = (arr, random) => arr[Math.floor((random ?? Math.random)() * arr.length)];
/**
* A function that generates a unique identifier by combining a random adjective, a random noun, and a random number (between 0 and 9999).
* The result is returned as a string with components separated by the specified separator.
* It is useful when you need to create unique identifiers that are also human-friendly.
*
* @param {string} [separator='_'] - The character used to separate the adjective, noun, and number. Defaults to '_' if not provided.
* @param {function} [rng=Math.random] - Random number generator function
* @returns {string} A unique, human-friendly identifier.
*
* @example
*
* let identifier = window.generate_identifier();
* // identifier would be something like 'clever-idea-123'
*
*/
function generate_identifier (separator = '_', rng = Math.random) {
// return a random combination of first_adj + noun + number (between 0 and 9999)
// e.g. clever-idea-123
return [
randomItem(adjectives, rng),
randomItem(nouns, rng),
Math.floor(rng() * 10000),
].join(separator);
}
// Character set used for generating human-readable, case-insensitive random codes
const HUMAN_READABLE_CASE_INSENSITIVE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
function generate_random_code (n, {
rng = Math.random,
chars = HUMAN_READABLE_CASE_INSENSITIVE,
} = {}) {
let code = '';
for ( let i = 0 ; i < n ; i++ ) {
code += randomItem(chars, rng);
}
return code;
}
/**
* Composes a code by combining a mask string with a base-36 converted number
* @param {string} mask - Initial string template to use as base
* @param {number} value - Number to convert to base-36 and append to the right
* @returns {string} Combined uppercase code
*/
function compose_code (mask, value) {
const right_str = value.toString(36);
let out_str = mask;
console.log('right_str', right_str);
console.log('out_str', out_str);
for ( let i = 0 ; i < right_str.length ; i++ ) {
out_str[out_str.length - 1 - i] = right_str[right_str.length - 1 - i];
}
out_str = out_str.toUpperCase();
return out_str;
}
module.exports = {
randomItem,
generate_identifier,
generate_random_code,
};
================================================
FILE: src/backend/src/modules/core/lib/linux.js
================================================
/*
* 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 .
*/
const parse_meminfo = text => {
const lines = text.split('\n');
let meminfo = {};
for ( const line of lines ) {
if ( line.trim().length == 0 ) continue;
const [keyPart, rest] = line.split(':');
if ( rest === undefined ) continue;
const key = keyPart.trim();
// rest looks like " 123 kB"; parseInt ignores the unit.
const value = Number.parseInt(rest, 10);
meminfo[key] = value;
}
return meminfo;
};
module.exports = {
parse_meminfo,
};
================================================
FILE: src/backend/src/modules/core/lib/log.js
================================================
/*
* 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 .
*/
const config = require('../../../config.js');
const module_epoch = Date.now();
const module_epoch_d = new Date();
const display_time = (now) => {
const pad2 = n => String(n).padStart(2, '0');
const yyyy = now.getFullYear();
const mm = pad2(now.getMonth() + 1);
const dd = pad2(now.getDate());
const HH = pad2(now.getHours());
const MM = pad2(now.getMinutes());
const SS = pad2(now.getSeconds());
const time = `${HH}:${MM}:${SS}`;
const needYear = yyyy !== module_epoch_d.getFullYear();
const needMonth = needYear || (now.getMonth() !== module_epoch_d.getMonth());
const needDay = needMonth || (now.getDate() !== module_epoch_d.getDate());
if ( needYear ) return `${yyyy}-${mm}-${dd} ${time}`;
if ( needMonth ) return `${mm}-${dd} ${time}`;
if ( needDay ) return `${dd} ${time}`;
return time;
};
// Example:
// log("booting"); // → "14:07:12 booting"
// (next day) log("tick"); // → "16 00:00:01 tick"
// (next month) log("tick"); // → "11-01 00:00:01 tick"
// (next year) log("tick"); // → "2026-01-01 00:00:01 tick"
/**
* Stringifies a log entry into a formatted string for console output.
* @param {Object} logEntry - The log entry object containing:
* @param {string} [prefix] - Optional prefix for the log message.
* @param {Object} log_lvl - Log level object with properties for label, escape code, etc.
* @param {string[]} crumbs - Array of context crumbs.
* @param {string} message - The log message.
* @param {Object} fields - Additional fields to be included in the log.
* @param {Object} objects - Objects to be logged.
* @returns {string} A formatted string representation of the log entry.
*/
const stringify_log_entry = ({ prefix, log_lvl, crumbs, message, fields, objects, stack }) => {
const { colorize } = require('json-colorizer');
let lines = [], m;
const lf = () => {
if ( ! m ) return;
lines.push(m);
m = '';
};
m = '';
if ( ! config.show_relative_time ) {
m += `${display_time(fields.timestamp)} `;
}
m += prefix ? `${prefix} ` : '';
let levelLabelShown = false;
if ( log_lvl.label !== 'INFO' || !config.log_hide_info_label ) {
levelLabelShown = true;
m += `\x1B[${log_lvl.esc}m[${log_lvl.label}\x1B[0m`;
} else {
m += `\x1B[${log_lvl.esc}m[\x1B[0m`;
}
for ( let crumb of crumbs ) {
if ( crumb.startsWith('extension/') ) {
crumb = `\x1B[34;1m${crumb}\x1B[0m`;
}
if ( levelLabelShown ) {
m += '::';
} else levelLabelShown = true;
m += crumb;
}
m += `\x1B[${log_lvl.esc}m]\x1B[0m`;
if ( fields.timestamp ) {
if ( config.show_relative_time ) {
// display seconds since logger epoch
const n = (fields.timestamp - module_epoch) / 1000;
m += ` (${n.toFixed(3)}s)`;
}
}
m += ` ${message} `;
lf();
for ( const k in fields ) {
// Extensions always have the system actor in context which makes logs
// too verbose. To combat this, we disable logging the 'actor' field
// when the actor's username is 'system' and the `crumbs` include a
// string that starts with 'extension'.
if ( k === 'actor' && crumbs.some(crumb => crumb.startsWith('extension/')) ) {
if ( typeof fields[k] === 'object' && fields[k]?.username === 'system' ) {
continue;
}
}
if ( k === 'timestamp' ) continue;
if ( k === 'stack' ) continue;
let v; try {
v = colorize(JSON.stringify(fields[k]));
} catch (e) {
v = `${ fields[k]}`;
}
m += ` \x1B[1m${k}:\x1B[0m ${v}`;
lf();
}
if ( fields.stack ) {
lines.push(fields.stack);
}
return lines.join('\n');
};
module.exports = {
stringify_log_entry,
};
================================================
FILE: src/backend/src/modules/core/lib/stdio.js
================================================
/*
* 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 .
*/
/**
* Strip ANSI escape sequences from a string (e.g. color codes)
* and then return the length of the resulting string.
*
* @param {string} str - The string to calculate visible length for
* @returns {number} The length of the string without ANSI escape sequences
*/
const visible_length = (str) => {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '').length;
};
/**
* Split a string into lines according to the terminal width,
* preserving ANSI escape sequences, and return an array of lines.
*
* @param {string} str The string to split into lines
* @returns {string[]} Array of lines split according to terminal width
*/
const split_lines = (str) => {
const lines = [];
let line = '';
let line_length = 0;
for ( const c of str ) {
line += c;
if ( c === '\n' ) {
lines.push(line);
line = '';
line_length = 0;
} else {
line_length++;
if ( line_length >= process.stdout.columns ) {
lines.push(line);
line = '';
line_length = 0;
}
}
}
if ( line.length ) {
lines.push(line);
}
return lines;
};
module.exports = {
visible_length,
split_lines,
};
================================================
FILE: src/backend/src/modules/data-access/AppRepository.js
================================================
export default class AppRepository {
//
}
================================================
FILE: src/backend/src/modules/data-access/AppService.comp.test.js
================================================
import { createTestKernel } from '../../../tools/test.mjs';
import { tmp_provide_services } from '../../helpers.js';
import AppES from '../../om/entitystorage/AppES';
import { AppLimitedES } from '../../om/entitystorage/AppLimitedES';
import { ESBuilder } from '../../om/entitystorage/ESBuilder';
import { MaxLimitES } from '../../om/entitystorage/MaxLimitES';
import { ProtectedAppES } from '../../om/entitystorage/ProtectedAppES';
import { SetOwnerES } from '../../om/entitystorage/SetOwnerES';
import SQLES from '../../om/entitystorage/SQLES';
import ValidationES from '../../om/entitystorage/ValidationES';
import WriteByOwnerOnlyES from '../../om/entitystorage/WriteByOwnerOnlyES';
import { Eq, Or } from '../../om/query/query';
import { Actor, UserActorType } from '../../services/auth/Actor';
import { VirtualGroupService } from '../../services/auth/VirtualGroupService';
import { EntityStoreService } from '../../services/EntityStoreService';
import { Context } from '../../util/esmcontext.js';
import { AppIconService } from '../apps/AppIconService';
import { AppInformationService } from '../apps/AppInformationService';
import { OldAppNameService } from '../apps/OldAppNameService';
import AppService from './AppService';
import config from '../../config.js';
import { describe, expect, it } from 'vitest';
const getHostedIndexUrl = subdomain => {
const hostedDomainCandidate = [
config.static_hosting_domain_alt,
config.static_hosting_domain,
config.private_app_hosting_domain_alt,
config.private_app_hosting_domain,
].find(domainValue => typeof domainValue === 'string' && domainValue.trim());
const hostedDomain = hostedDomainCandidate
? hostedDomainCandidate.trim().toLowerCase().replace(/^\./, '').split(':')[0]
: 'site.puter.localhost';
return `https://${subdomain}.${hostedDomain}`;
};
const ES_APP_ARGS = {
entity: 'app',
upstream: ESBuilder.create([
SQLES, { table: 'app', debug: true },
AppES,
AppLimitedES, {
permission_prefix: 'apps-of-user',
exception: async () => {
const actor = Context.get('actor');
return new Or({
children: [
new Eq({
key: 'approved_for_listing',
value: 1,
}),
new Eq({
key: 'uid',
value: actor.type.app.uid,
}),
],
});
},
},
WriteByOwnerOnlyES,
ValidationES,
SetOwnerES,
ProtectedAppES,
MaxLimitES, { max: 5000 },
]),
};
// Fix: Manually initialize AsyncLocalStorage store for Vitest
// Under Vitest, AsyncLocalStorage may not have a store initialized, causing Context.get() to fail.
// This manually creates a store and sets the root context, ensuring Context operations work.
// This may be a side-effect of OpenTelemetry's own use of AsyncLocalStorage.
const fixContextInitialization = async (callback) => {
return await Context.contextAsyncLocalStorage.run(Context.root, async () => {
Context.contextAsyncLocalStorage.getStore().set('context', Context.root);
return await callback();
});
};
const testWithEachService = async (fnToRunOnBoth, {
fnToRunOnTheOther,
} = {}) => {
return await fixContextInitialization(async () => {
const setupUserAndRunWithContext = async (params, fn) => {
const { kernel } = params;
const db = kernel.services.get('database').get('write', 'test');
const userId = 1;
const username = 'testuser';
const uuid = `user-uuid-${userId}`;
// Insert the user into the database if not exists
const existingUser = await kernel.services.get('database')
.get('read', 'test')
.read('SELECT * FROM user WHERE uuid = ?', [uuid]);
if ( existingUser.length === 0 ) {
await db.write(
'INSERT INTO user (uuid, username, free_storage) VALUES (?, ?, ?)',
[uuid, username, 1024 * 1024 * 1024],
);
}
// Read the user back to get the actual id
const users = await kernel.services.get('database')
.get('read', 'test')
.read('SELECT * FROM user WHERE uuid = ?', [uuid]);
const user = users[0];
if ( ! user ) {
throw new Error('Failed to create or retrieve test user');
}
const actor = await Actor.create(UserActorType, { user });
if ( !actor || !actor.type ) {
throw new Error('Failed to create actor');
}
const userContext = kernel.root_context.sub({
user,
actor,
});
await userContext.arun(async () => {
Context.set('actor', actor);
await fn({ ...params, user, actor });
});
};
const esAppTestKernel = await createTestKernel({
testCore: true,
initLevelString: 'init',
serviceMap: {
'app-information': AppInformationService,
'app-icon': AppIconService,
'old-app-name': OldAppNameService,
'virtual-group': VirtualGroupService,
'es:app': EntityStoreService,
},
serviceMapArgs: {
'es:app': ES_APP_ARGS,
},
});
await tmp_provide_services(esAppTestKernel.services);
const appTestKernel = await createTestKernel({
testCore: true,
initLevelString: 'init',
serviceMap: {
'app-information': AppInformationService,
'app-icon': AppIconService,
'old-app-name': OldAppNameService,
'virtual-group': VirtualGroupService,
'app': AppService,
},
});
await tmp_provide_services(appTestKernel.services);
tmp_provide_services(appTestKernel.services);
await setupUserAndRunWithContext({ kernel: appTestKernel, key: 'app' }, fnToRunOnBoth);
tmp_provide_services(esAppTestKernel.services);
if ( fnToRunOnTheOther ) {
await setupUserAndRunWithContext({ kernel: esAppTestKernel, key: 'es:app' }, fnToRunOnTheOther);
} else {
await setupUserAndRunWithContext({ kernel: esAppTestKernel, key: 'es:app' }, fnToRunOnBoth);
}
// Expect these tables to have the same values:
const relevant_tables = ['apps', 'app_filetype_association'];
// Fields that are expected to differ (auto-generated UUIDs, timestamps)
const volatile_fields = ['uid', 'uuid', 'timestamp'];
const stripVolatile = (rows) => rows.map(row => {
const copy = { ...row };
for ( const field of volatile_fields ) {
delete copy[field];
}
return copy;
});
const db_esApp = esAppTestKernel.services.get('database').get('write', 'test');
const db_app = appTestKernel.services.get('database').get('write', 'test');
for ( const table_name of relevant_tables ) {
const rows_esApp = await db_esApp.read(`SELECT * FROM ${table_name}`);
const rows_app = await db_app.read(`SELECT * FROM ${table_name}`);
expect(stripVolatile(rows_app)).toEqual(stripVolatile(rows_esApp));
}
});
};
describe('AppService Regression Prevention Tests', () => {
it('should be testable with two test kernels', async () => {
await testWithEachService(() => {
});
});
it('test utility detects database deviations as expected', async () => {
// This should fail because we create apps with different names
let assertionErrorThrown = false;
try {
await testWithEachService(
async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
await crudQ.create.call(service, {
object: {
name: 'test-app',
title: 'Test App',
index_url: 'https://example.com',
},
});
},
{
fnToRunOnTheOther: async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create app with DIFFERENT name to cause deviation
await crudQ.create.call(service, {
object: {
name: 'different-app', // Different name!
title: 'Different Test App',
index_url: 'https://example.com',
},
});
},
},
);
} catch ( error ) {
// Vitest assertion errors are thrown when expect() fails
// Check if it's an AssertionError or has assertion-related properties
if ( error.name === 'AssertionError' ||
error.constructor.name === 'AssertionError' ||
(error.message && error.message.includes('toEqual')) ) {
assertionErrorThrown = true;
} else {
// Re-throw if it's not an assertion error
throw error;
}
}
// Verify that the assertion error was thrown (meaning deviation was detected)
expect(assertionErrorThrown).toBe(true);
});
describe('create', () => {
it('should create the app', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
await crudQ.create.call(service, {
object: {
name: 'test-app',
title: 'Test App',
index_url: 'https://example.com',
},
});
});
});
});
describe('read', () => {
it('should read app by uid', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create an app
const created = await crudQ.create.call(service, {
object: {
name: 'read-test-app',
title: 'Read Test App',
index_url: 'https://example.com',
},
});
// Read it back by uid
const read = await crudQ.read.call(service, { uid: created.uid });
expect(read).toBeDefined();
expect(read.name).toBe('read-test-app');
expect(read.title).toBe('Read Test App');
});
});
it('should read app by name', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create an app
await crudQ.create.call(service, {
object: {
name: 'named-app',
title: 'Named App',
index_url: 'https://example.com',
},
});
// Read it back by name
const read = await crudQ.read.call(service, { id: { name: 'named-app' } });
expect(read).toBeDefined();
expect(read.name).toBe('named-app');
expect(read.title).toBe('Named App');
});
});
it('should throw error for non-existent app', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Try to read a non-existent app - should throw entity_not_found
let errorThrown = false;
try {
await crudQ.read.call(service, { uid: 'app-nonexistent-uid' });
} catch ( error ) {
errorThrown = true;
const code = error.fields?.code || error.code;
expect(code).toBe('entity_not_found');
}
expect(errorThrown).toBe(true);
});
});
});
describe('update', () => {
it('should update title and description', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create an app
const created = await crudQ.create.call(service, {
object: {
name: 'update-test-app',
title: 'Original Title',
description: 'Original description',
index_url: 'https://example.com',
},
});
// Update title and description
await crudQ.update.call(service, {
object: {
uid: created.uid,
title: 'Updated Title',
description: 'Updated description',
},
id: { name: 'update-test-app' },
});
const read = await crudQ.read.call(service, { uid: created.uid });
expect(read.title).toBe('Updated Title');
expect(read.description).toBe('Updated description');
});
});
it('should update index_url', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create an app
const created = await crudQ.create.call(service, {
object: {
name: 'url-update-app',
title: 'URL Update App',
index_url: 'https://old-url.com',
},
});
// Update index_url
await crudQ.update.call(service, {
object: {
uid: created.uid,
index_url: 'https://new-url.com',
},
id: { name: 'url-update-app' },
});
const read = await crudQ.read.call(service, { uid: created.uid });
expect(read.index_url).toBe('https://new-url.com');
});
});
it('should update with filetype_associations', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create an app
const created = await crudQ.create.call(service, {
object: {
name: 'filetype-app',
title: 'Filetype App',
index_url: 'https://example.com',
},
});
// Update with filetype associations (include title to avoid empty SET clause)
await crudQ.update.call(service, {
object: {
uid: created.uid,
title: 'Filetype App Updated',
filetype_associations: ['txt', 'md', 'json'],
},
id: { name: 'filetype-app' },
});
const read = await crudQ.read.call(service, { uid: created.uid });
expect(read.title).toBe('Filetype App Updated');
expect(read.filetype_associations).toEqual(
expect.arrayContaining(['txt', 'md', 'json']),
);
});
});
it('should update name with dedupe_name option', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create two apps
await crudQ.create.call(service, {
object: {
name: 'taken-name',
title: 'First App',
index_url: 'https://example.com/taken-name',
},
});
const second = await crudQ.create.call(service, {
object: {
name: 'second-app',
title: 'Second App',
index_url: 'https://example.com/second-app',
},
});
// Try to update second app to use first app's name with dedupe
await crudQ.update.call(service, {
object: {
uid: second.uid,
name: 'taken-name',
},
id: { name: 'second-app' },
options: { dedupe_name: true },
});
const read = await crudQ.read.call(service, { uid: second.uid });
// Should have been deduped to taken-name-1
expect(read.name).toBe('taken-name-1');
});
});
it('should throw error when updating non-existent app', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
let errorThrown = false;
try {
await crudQ.update.call(service, {
object: {
uid: 'app-nonexistent',
title: 'New Title',
},
id: { name: 'nonexistent-app' },
});
} catch ( error ) {
errorThrown = true;
// Error code is in fields.code for APIError
const code = error.fields?.code || error.code;
expect(code).toBe('entity_not_found');
}
expect(errorThrown).toBe(true);
});
});
});
describe('upsert', () => {
it('should create when app does not exist', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Upsert a new app (should create)
const result = await crudQ.upsert.call(service, {
object: {
name: 'upsert-new-app',
title: 'Upsert New App',
index_url: 'https://example.com',
},
});
expect(result).toBeDefined();
expect(result.name).toBe('upsert-new-app');
// Verify it was created
const read = await crudQ.read.call(service, { id: { name: 'upsert-new-app' } });
expect(read).toBeDefined();
expect(read.title).toBe('Upsert New App');
});
});
it('should update when app exists', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create an app first
const created = await crudQ.create.call(service, {
object: {
name: 'upsert-existing-app',
title: 'Original Title',
index_url: 'https://example.com',
},
});
// Upsert with same uid (should update)
await crudQ.upsert.call(service, {
object: {
uid: created.uid,
title: 'Updated via Upsert',
},
id: { name: 'upsert-existing-app' },
});
// Verify it was updated
const read = await crudQ.read.call(service, { uid: created.uid });
expect(read.title).toBe('Updated via Upsert');
});
});
});
describe('select', () => {
it('should select all apps', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create multiple apps
await crudQ.create.call(service, {
object: {
name: 'select-app-1',
title: 'Select App 1',
index_url: 'https://example.com/select-app-1',
},
});
await crudQ.create.call(service, {
object: {
name: 'select-app-2',
title: 'Select App 2',
index_url: 'https://example.com/select-app-2',
},
});
await crudQ.create.call(service, {
object: {
name: 'select-app-3',
title: 'Select App 3',
index_url: 'https://example.com/select-app-3',
},
});
// Select all
const apps = await crudQ.select.call(service, {});
expect(apps.length).toBeGreaterThanOrEqual(3);
const names = apps.map(app => app.name);
expect(names).toContain('select-app-1');
expect(names).toContain('select-app-2');
expect(names).toContain('select-app-3');
});
});
it('should select with user-can-edit predicate', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create an app
await crudQ.create.call(service, {
object: {
name: 'editable-app',
title: 'Editable App',
index_url: 'https://example.com',
},
});
// Select with user-can-edit predicate
const apps = await crudQ.select.call(service, {
predicate: ['user-can-edit'],
});
// Should return the app since it's owned by the current user
const names = apps.map(app => app.name);
expect(names).toContain('editable-app');
});
});
});
describe('delete', () => {
it('should delete app by uid', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create an app
const created = await crudQ.create.call(service, {
object: {
name: 'delete-test-app',
title: 'Delete Test App',
index_url: 'https://example.com',
},
});
// Delete it
await crudQ.delete.call(service, { uid: created.uid });
// Verify it's gone - should throw entity_not_found
let errorThrown = false;
try {
await crudQ.read.call(service, { uid: created.uid });
} catch ( error ) {
errorThrown = true;
const code = error.fields?.code || error.code;
expect(code).toBe('entity_not_found');
}
expect(errorThrown).toBe(true);
});
});
it('should throw error when deleting non-existent app', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
let errorThrown = false;
try {
await crudQ.delete.call(service, { uid: 'app-nonexistent' });
} catch ( error ) {
errorThrown = true;
// Error code is in fields.code for APIError
const code = error.fields?.code || error.code;
expect(code).toBe('entity_not_found');
}
expect(errorThrown).toBe(true);
});
});
});
describe('edge cases', () => {
it('should throw validation error for invalid app name', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
let errorThrown = false;
try {
await crudQ.create.call(service, {
object: {
name: 'invalid name with spaces!',
title: 'Invalid App',
index_url: 'https://example.com',
},
});
} catch ( error ) {
errorThrown = true;
// Validation errors have specific codes in fields.code
const code = error.fields?.code || error.code;
expect(code).toBeDefined();
}
expect(errorThrown).toBe(true);
});
});
it('should throw error for missing required field', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
let errorThrown = false;
try {
await crudQ.create.call(service, {
object: {
name: 'missing-title-app',
// Missing title!
index_url: 'https://example.com',
},
});
} catch ( error ) {
errorThrown = true;
const code = error.fields?.code || error.code;
expect(code).toBe('field_missing');
}
expect(errorThrown).toBe(true);
});
});
it('should throw error for name conflict without dedupe', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create first app
await crudQ.create.call(service, {
object: {
name: 'conflict-name',
title: 'First App',
index_url: 'https://example.com/conflict-name-1',
},
});
// Try to create second app with same name
let errorThrown = false;
try {
await crudQ.create.call(service, {
object: {
name: 'conflict-name',
title: 'Second App',
index_url: 'https://example.com/conflict-name-2',
},
});
} catch ( error ) {
errorThrown = true;
const code = error.fields?.code || error.code;
expect(code).toBe('app_name_already_in_use');
}
expect(errorThrown).toBe(true);
});
});
it('should allow duplicate dev-center placeholder index_url', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
await crudQ.create.call(service, {
object: {
name: 'placeholder-app-1',
title: 'Placeholder App 1',
index_url: 'https://dev-center.puter.com/coming-soon.html',
},
});
const second = await crudQ.create.call(service, {
object: {
name: 'placeholder-app-2',
title: 'Placeholder App 2',
index_url: 'https://dev-center.puter.com/coming-soon.html',
},
});
expect(second.uid).toBeDefined();
});
});
it('should allow duplicate non-hosted index_url', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
const first = await crudQ.create.call(service, {
object: {
name: 'non-hosted-duplicate-1',
title: 'Non Hosted Duplicate 1',
index_url: 'https://example.com/shared-origin',
},
});
const second = await crudQ.create.call(service, {
object: {
name: 'non-hosted-duplicate-2',
title: 'Non Hosted Duplicate 2',
index_url: 'https://example.com/shared-origin',
},
});
expect(first.uid).toBeDefined();
expect(second.uid).toBeDefined();
expect(second.uid).not.toBe(first.uid);
});
});
it('should join existing unowned hosted index_url app on create', async () => {
await testWithEachService(async ({ kernel, key, user }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
const db = kernel.services.get('database').get('write', 'test');
const hostedIndexUrl = getHostedIndexUrl('joinable-site');
const existingUid = 'app-11111111-1111-4111-8111-111111111111';
kernel.services.set('puter-site', {
get_subdomain: async (subdomain) => {
const rows = await db.read(
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
[subdomain],
);
return rows[0] || null;
},
});
await db.write(
'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)',
['sd-11111111-1111-4111-8111-111111111111', 'joinable-site', user.id, 111],
);
await db.write(
'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)',
[existingUid, 'joinable-existing-app', 'Joinable Existing App', 'Created from origin', hostedIndexUrl, null],
);
const joined = await crudQ.create.call(service, {
object: {
name: 'joinable-hosted-app',
title: 'Joinable Hosted App',
description: 'Claimed by owner',
index_url: hostedIndexUrl,
},
});
expect(joined.uid).toBe(existingUid);
const joinedRows = await db.read(
'SELECT uid, name, owner_user_id FROM apps WHERE index_url = ?',
[hostedIndexUrl],
);
expect(joinedRows).toHaveLength(1);
expect(joinedRows[0].uid).toBe(existingUid);
expect(joinedRows[0].name).toBe('joinable-hosted-app');
expect(joinedRows[0].owner_user_id).toBe(user.id);
});
});
it('should join existing unowned hosted index_url app on update', async () => {
await testWithEachService(async ({ kernel, key, user }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
const db = kernel.services.get('database').get('write', 'test');
const hostedIndexUrl = getHostedIndexUrl('joinable-update-site');
const existingUid = 'app-33333333-3333-4333-8333-333333333333';
kernel.services.set('puter-site', {
get_subdomain: async (subdomain) => {
const rows = await db.read(
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
[subdomain],
);
return rows[0] || null;
},
});
await db.write(
'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)',
['sd-33333333-3333-4333-8333-333333333333', 'joinable-update-site', user.id, 333],
);
await db.write(
'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)',
[existingUid, 'joinable-update-existing', 'Joinable Update Existing', 'Auto-created app', hostedIndexUrl, null],
);
const appToUpdate = await crudQ.create.call(service, {
object: {
name: 'joinable-update-source',
title: 'Joinable Update Source',
description: 'Source app to be merged',
index_url: 'https://example.com/update-source',
},
});
const joined = await crudQ.update.call(service, {
object: {
uid: appToUpdate.uid,
name: 'joinable-update-merged',
title: 'Joinable Update Merged',
description: 'Merged by owner',
index_url: hostedIndexUrl,
},
});
expect(joined.uid).toBe(existingUid);
const joinedRows = await db.read(
'SELECT uid, name, title, owner_user_id FROM apps WHERE index_url = ?',
[hostedIndexUrl],
);
expect(joinedRows).toHaveLength(1);
expect(joinedRows[0].uid).toBe(existingUid);
expect(joinedRows[0].name).toBe('joinable-update-merged');
expect(joinedRows[0].title).toBe('Joinable Update Merged');
expect(joinedRows[0].owner_user_id).toBe(user.id);
const sourceRows = await db.read(
'SELECT uid FROM apps WHERE uid = ?',
[appToUpdate.uid],
);
expect(sourceRows).toHaveLength(0);
const aliasedRead = await crudQ.read.call(service, {
uid: appToUpdate.uid,
});
expect(aliasedRead.uid).toBe(existingUid);
});
});
it('should join on update when name matches source app name', async () => {
await testWithEachService(async ({ kernel, key, user }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
const db = kernel.services.get('database').get('write', 'test');
const hostedIndexUrl = getHostedIndexUrl('joinable-update-self-name');
const existingUid = 'app-44444444-4444-4444-8444-444444444444';
kernel.services.set('puter-site', {
get_subdomain: async (subdomain) => {
const rows = await db.read(
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
[subdomain],
);
return rows[0] || null;
},
});
await db.write(
'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)',
['sd-44444444-4444-4444-8444-444444444444', 'joinable-update-self-name', user.id, 444],
);
await db.write(
'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)',
[existingUid, 'existing-target-name', 'Existing Target', 'Auto-created app', hostedIndexUrl, null],
);
const source = await crudQ.create.call(service, {
object: {
name: 'staging-app-center',
title: 'Source App',
description: 'Source app before join',
index_url: 'https://example.com/staging-source',
},
});
const joined = await crudQ.update.call(service, {
object: {
uid: source.uid,
name: 'staging-app-center',
title: 'Merged Title',
index_url: hostedIndexUrl,
},
});
expect(joined.uid).toBe(existingUid);
const targetRows = await db.read(
'SELECT uid, name, title FROM apps WHERE uid = ?',
[existingUid],
);
expect(targetRows).toHaveLength(1);
expect(targetRows[0].name).toBe('staging-app-center');
expect(targetRows[0].title).toBe('Merged Title');
const sourceRows = await db.read(
'SELECT uid FROM apps WHERE uid = ?',
[source.uid],
);
expect(sourceRows).toHaveLength(0);
const aliasedRead = await crudQ.read.call(service, {
uid: source.uid,
});
expect(aliasedRead.uid).toBe(existingUid);
});
});
it('should join owned bootstrap hosted app on update', async () => {
await testWithEachService(async ({ kernel, key, user }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
const db = kernel.services.get('database').get('write', 'test');
const hostedIndexUrl = getHostedIndexUrl('joinable-owned-bootstrap');
const existingUid = 'app-55555555-5555-4555-8555-555555555555';
kernel.services.set('puter-site', {
get_subdomain: async (subdomain) => {
const rows = await db.read(
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
[subdomain],
);
return rows[0] || null;
},
});
await db.write(
'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)',
['sd-55555555-5555-4555-8555-555555555555', 'joinable-owned-bootstrap', user.id, 555],
);
await db.write(
'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)',
[
existingUid,
existingUid,
existingUid,
`App created from origin ${hostedIndexUrl}`,
hostedIndexUrl,
user.id,
],
);
const source = await crudQ.create.call(service, {
object: {
name: 'owned-bootstrap-source',
title: 'Owned Bootstrap Source',
description: 'Source app to be merged',
index_url: 'https://example.com/owned-bootstrap-source',
},
});
const joined = await crudQ.update.call(service, {
object: {
uid: source.uid,
title: 'Merged Bootstrap Title',
index_url: hostedIndexUrl,
},
});
expect(joined.uid).toBe(existingUid);
const targetRows = await db.read(
'SELECT uid, title, owner_user_id FROM apps WHERE uid = ?',
[existingUid],
);
expect(targetRows).toHaveLength(1);
expect(targetRows[0].title).toBe('Merged Bootstrap Title');
expect(targetRows[0].owner_user_id).toBe(user.id);
});
});
it('should reject hosted duplicate index_url owned by another user', async () => {
await testWithEachService(async ({ kernel, key, user }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
const db = kernel.services.get('database').get('write', 'test');
const hostedIndexUrl = getHostedIndexUrl('foreign-owned');
kernel.services.set('puter-site', {
get_subdomain: async (subdomain) => {
const rows = await db.read(
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
[subdomain],
);
return rows[0] || null;
},
});
await db.write(
'INSERT INTO user (uuid, username, free_storage) VALUES (?, ?, ?)',
['user-uuid-2', 'otheruser', 1024 * 1024 * 1024],
);
const otherUsers = await db.read('SELECT id FROM user WHERE uuid = ?', ['user-uuid-2']);
const otherUserId = otherUsers[0].id;
await db.write(
'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)',
['sd-22222222-2222-4222-8222-222222222222', 'foreign-owned', user.id, 222],
);
await db.write(
'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)',
['app-22222222-2222-4222-8222-222222222222', 'foreign-owned-existing', 'Foreign Owned Existing', 'Owned by another user', hostedIndexUrl, otherUserId],
);
let errorThrown = false;
try {
await crudQ.create.call(service, {
object: {
name: 'foreign-owned-new',
title: 'Foreign Owned New',
index_url: hostedIndexUrl,
},
});
} catch ( error ) {
errorThrown = true;
const code = error.fields?.code || error.code;
expect(code).toBe('app_index_url_already_in_use');
}
expect(errorThrown).toBe(true);
});
});
it('should dedupe name with dedupe_name option', async () => {
await testWithEachService(async ({ kernel, key }) => {
const service = kernel.services.get(key);
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
// Create first app
await crudQ.create.call(service, {
object: {
name: 'dedupe-name',
title: 'First App',
index_url: 'https://example.com/dedupe-name-1',
},
});
// Create second app with same name but dedupe option
const second = await crudQ.create.call(service, {
object: {
name: 'dedupe-name',
title: 'Second App',
index_url: 'https://example.com/dedupe-name-2',
},
options: { dedupe_name: true },
});
// Should be deduped to dedupe-name-1
expect(second.name).toBe('dedupe-name-1');
});
});
});
});
================================================
FILE: src/backend/src/modules/data-access/AppService.js
================================================
import { v4 as uuidv4 } from 'uuid';
import APIError from '../../api/APIError.js';
import { deleteRedisKeys } from '../../clients/redis/deleteRedisKeys.js';
import config from '../../config.js';
import { APP_ICONS_SUBDOMAIN } from '../../consts/app-icons.js';
import { NodeInternalIDSelector } from '../../filesystem/node/selectors.js';
import { app_name_exists, get_app } from '../../helpers.js';
import { AppUnderUserActorType, UserActorType } from '../../services/auth/Actor.js';
import { PERMISSION_FOR_NOTHING_IN_PARTICULAR, PermissionRewriter, PermissionUtil } from '../../services/auth/permissionUtils.mjs';
import BaseService from '../../services/BaseService.js';
import { DB_READ, DB_WRITE } from '../../services/database/consts.js';
import { Context } from '../../util/context.js';
import { AppRedisCacheSpace } from '../apps/AppRedisCacheSpace.js';
import AppRepository from './AppRepository.js';
import { as_bool } from './lib/coercion.js';
import { user_to_client } from './lib/filter.js';
import { extract_from_prefix } from './lib/sqlutil.js';
import {
validate_array_of_strings,
validate_image_base64,
validate_json,
validate_string,
validate_url,
} from './lib/validation.js';
const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/([^/?#]+)(?:\/(\d+))?\/?$/;
const LEGACY_APP_ICON_FILE_PATH_REGEX = /^\/(app-[^/?#]+?)(?:-(\d+))?\.png$/;
const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias';
const APP_UID_ALIAS_REVERSE_KEY_PREFIX = 'app:canonicalUidAliasReverse';
const APP_UID_ALIAS_TTL_SECONDS = 60 * 60 * 24 * 90;
const indexUrlUniquenessExemptionCandidates = [
'https://dev-center.puter.com/coming-soon',
];
const isAbsoluteUrl = value => ABSOLUTE_URL_REGEX.test(value) || value.startsWith('//');
const hasIndexUrlUniquenessExemption = (candidates) => {
for ( const candidate of candidates ) {
if ( indexUrlUniquenessExemptionCandidates.find(exception => candidate.startsWith(exception)) ) {
return true;
}
}
return false;
};
const isRawBase64ImageString = value => {
if ( typeof value !== 'string' ) return false;
const trimmed = value.trim();
if ( !trimmed || trimmed.length < 16 ) return false;
if ( ! RAW_BASE64_REGEX.test(trimmed) ) return false;
if ( trimmed.length % 4 !== 0 ) return false;
try {
const decoded = Buffer.from(trimmed, 'base64');
if ( decoded.length === 0 ) return false;
const normalizedInput = trimmed.replace(/=+$/, '');
const reencoded = decoded.toString('base64').replace(/=+$/, '');
return normalizedInput === reencoded;
} catch {
return false;
}
};
const normalizeRawBase64ImageString = value => {
if ( typeof value !== 'string' ) return value;
const trimmed = value.trim();
if ( ! isRawBase64ImageString(trimmed) ) return value;
return `data:image/png;base64,${trimmed}`;
};
const isStoredBase64AppIcon = ({ icon, icon_is_base64: iconIsBase64 }) => {
if ( typeof iconIsBase64 === 'boolean' ) return iconIsBase64;
if ( typeof iconIsBase64 === 'number' ) return iconIsBase64 !== 0;
if ( typeof iconIsBase64 === 'string' ) {
const normalized = iconIsBase64.toLowerCase();
if ( normalized === '1' || normalized === 'true' ) return true;
if ( normalized === '0' || normalized === 'false' ) return false;
}
if ( typeof icon !== 'string' ) return false;
const trimmed = icon.trim();
if ( trimmed.startsWith('data:image/') ) return true;
return isRawBase64ImageString(trimmed);
};
const getCanonicalAppIconBaseUrl = () => {
const candidate = [config.api_base_url, config.origin]
.find(value => typeof value === 'string' && value.trim());
if ( ! candidate ) return null;
try {
return (new URL(candidate)).origin;
} catch {
return null;
}
};
const getAllowedAppIconOrigins = () => {
const origins = new Set();
for ( const candidate of [config.api_base_url, config.origin] ) {
if ( typeof candidate !== 'string' || !candidate ) continue;
try {
origins.add((new URL(candidate)).origin);
} catch {
// Ignore invalid config values.
}
}
return origins;
};
const getAllowedLegacyAppIconHostnames = () => {
const hostnames = new Set();
const domains = [config.static_hosting_domain, config.static_hosting_domain_alt];
for ( const domain of domains ) {
if ( typeof domain !== 'string' || !domain.trim() ) continue;
hostnames.add(`${APP_ICONS_SUBDOMAIN}.${domain.trim().toLowerCase()}`);
}
return hostnames;
};
const normalizeAppUid = appUid => (
typeof appUid === 'string' && appUid.startsWith('app-')
? appUid
: `app-${appUid}`
);
const parseAppIconEndpointPath = (value) => {
if ( typeof value !== 'string' ) return null;
const trimmed = value.trim();
if ( ! trimmed ) return null;
try {
const parsed = new URL(trimmed, 'http://localhost');
const match = parsed.pathname.match(APP_ICON_ENDPOINT_PATH_REGEX);
if ( ! match ) return null;
return {
appUid: normalizeAppUid(match[1]),
};
} catch {
return null;
}
};
const isAppIconEndpointPath = value => !!parseAppIconEndpointPath(value);
const isAllowedAppIconEndpointUrl = value => {
if ( ! isAppIconEndpointPath(value) ) return false;
const trimmed = value.trim();
if ( ! isAbsoluteUrl(trimmed) ) {
return true;
}
try {
const parsed = new URL(trimmed, 'http://localhost');
return getAllowedAppIconOrigins().has(parsed.origin);
} catch {
return false;
}
};
const parseLegacyHostedAppIconToEndpointPath = value => {
if ( typeof value !== 'string' ) return null;
const trimmed = value.trim();
if ( !trimmed || trimmed.startsWith('data:') ) return null;
let parsed;
try {
parsed = new URL(trimmed, 'http://localhost');
} catch {
return null;
}
if ( isAbsoluteUrl(trimmed) ) {
const allowedHostnames = getAllowedLegacyAppIconHostnames();
const hostname = parsed.hostname.toLowerCase();
if ( ! allowedHostnames.has(hostname) ) {
return null;
}
}
const match = parsed.pathname.match(LEGACY_APP_ICON_FILE_PATH_REGEX);
if ( ! match ) return null;
const appUid = normalizeAppUid(match[1]);
return `/app-icon/${appUid}`;
};
const migrateRelativeAppIconEndpointUrl = value => {
if ( typeof value !== 'string' ) return value;
const trimmed = value.trim();
if ( ! trimmed ) return value;
let canonicalEndpointPath = null;
const endpointPath = parseAppIconEndpointPath(trimmed);
if ( endpointPath ) {
if ( isAbsoluteUrl(trimmed) ) {
try {
const parsed = new URL(trimmed, 'http://localhost');
if ( ! getAllowedAppIconOrigins().has(parsed.origin) ) {
return value;
}
} catch {
return value;
}
}
canonicalEndpointPath = `/app-icon/${endpointPath.appUid}`;
} else {
canonicalEndpointPath = parseLegacyHostedAppIconToEndpointPath(trimmed);
}
if ( ! canonicalEndpointPath ) return value;
const baseUrl = getCanonicalAppIconBaseUrl();
if ( ! baseUrl ) return canonicalEndpointPath;
try {
return new URL(canonicalEndpointPath, `${baseUrl}/`).toString();
} catch {
return canonicalEndpointPath;
}
};
/**
* AppService contains an instance using the repository pattern
*/
export default class AppService extends BaseService {
async _init () {
this.repository = new AppRepository();
this.db = this.services.get('database').get(DB_READ, 'apps');
this.db_write = this.services.get('database').get(DB_WRITE, 'apps');
const svc_permission = this.services.get('permission');
const svc_app = this;
// Rewrite app-root-dir:: to fs::
svc_permission.register_rewriter(PermissionRewriter.create({
matcher: permission => permission.startsWith('app-root-dir:'),
rewriter: async permission => {
const context = Context.get();
// Only "AppUnderUser" scope is allowed to have this permission rewritten to
// an actual filesystem permission - this is because apps will still be limited
// baesd on a user's own access.
const actor = context.get('actor');
if ( ! Context.get('is_grant_user_app_permission') ) {
return PERMISSION_FOR_NOTHING_IN_PARTICULAR;
}
const parts = PermissionUtil.split(permission);
if ( parts.length < 3 ) {
throw APIError.create('field_invalid', null, { key: 'permission', got: permission });
}
// <>::
const target_app_uid = parts[1];
const access = parts[2];
if ( ! target_app_uid ) {
throw APIError.create('field_invalid', null, { key: 'target_app_uid', got: target_app_uid });
}
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
const target_app = await get_app({ uid: target_app_uid });
if ( ! target_app ) {
throw APIError.create('entity_not_found', null, { identifier: `app:${target_app_uid}` });
}
if ( target_app.owner_user_id !== actor.type.user.id ) {
throw APIError.create('forbidden');
}
const root_dir_id = await svc_app.getAppRootDirId(target_app);
const svc_fs = context.get('services').get('filesystem');
const node = await svc_fs.node(new NodeInternalIDSelector('mysql', root_dir_id));
await node.fetchEntry();
if ( ! node.found ) throw APIError.create('subject_does_not_exist');
const node_uid = await node.get('uid');
return PermissionUtil.join('fs', node_uid, access);
},
}));
}
static PROTECTED_FIELDS = ['last_review'];
static READ_ONLY_FIELDS = [
'approved_for_listing',
'approved_for_opening_items',
'approved_for_incentive_program',
'godmode',
'is_private',
];
static WRITE_ALL_OWNER_PERMISSION = 'system:es:write-all-owners';
static IMPLEMENTS = {
'crud-q': {
async create ({ object, options }) {
return await this.#create({ object, options });
},
async update ({ object, id, options }) {
return await this.#update({ object, id, options });
},
async upsert ({ object, id, options }) {
// Try to find an existing entity
let existing = null;
if ( object.uid !== undefined || id !== undefined ) {
try {
existing = await this.#read({
uid: object.uid,
id,
});
} catch ( error ) {
// If entity not found, we'll create it
if ( error.fields?.code !== 'entity_not_found' ) {
throw error;
}
}
}
if ( existing ) {
// Entity exists, call update
return await this.#update({ object, id, options });
} else {
// Entity doesn't exist, call create
return await this.#create({ object, options });
}
},
async read ({ uid, id, params = {} }) {
return this.#read({ uid, id, params });
},
async select (options) {
return this.#select(options);
},
async delete ({ uid, id }) {
return await this.#delete({ uid, id });
},
},
};
// value of require('om/mappings/app.js').redundant_identifiers
static REDUNDANT_IDENTIFIERS = ['name'];
async #select ({ predicate, params, ..._rest }) {
const db = this.db;
if ( predicate === undefined ) predicate = [];
if ( params === undefined ) params = {};
if ( ! Array.isArray(predicate) ) throw new Error('predicate must be an array');
const userCanEditOnly = Array.prototype.includes.call(predicate, 'user-can-edit');
const stmt = 'SELECT apps.*, ' +
'CASE WHEN apps.icon LIKE \'data:%\' THEN 1 ELSE 0 END AS icon_is_base64, ' +
'owner_user.username AS owner_user_username, ' +
'owner_user.uuid AS owner_user_uuid, ' +
'app_owner.uid AS app_owner_uid ' +
'FROM apps ' +
'LEFT JOIN user owner_user ON apps.owner_user_id = owner_user.id ' +
'LEFT JOIN apps app_owner ON apps.app_owner = app_owner.id ' +
`${userCanEditOnly ? 'WHERE apps.owner_user_id=?' : ''} ` +
'LIMIT 5000';
const values = userCanEditOnly ? [Context.get('user').id] : [];
const rows = await db.read(stmt, values);
const shouldFetchFiletypes = rows.some(row => typeof row.filetypes !== 'string');
const filetypesByAppId = shouldFetchFiletypes
? await this.#getFiletypeAssociationsByAppIds(rows.map(row => row.id))
: new Map();
const iconSize = params.icon_size;
const shouldResolveIconPath = Boolean(iconSize)
|| rows.some(row => isStoredBase64AppIcon(row));
const svc_appIcon = shouldResolveIconPath
? this.context.get('services').get('app-icon')
: null;
const svc_error = shouldResolveIconPath
? this.context.get('services').get('error-service')
: null;
const appAndOwnerIds = [];
for ( const row of rows ) {
const app = {};
// FROM ROW
app.approved_for_incentive_program = as_bool(row.approved_for_incentive_program);
app.approved_for_listing = as_bool(row.approved_for_listing);
app.approved_for_opening_items = as_bool(row.approved_for_opening_items);
app.background = as_bool(row.background);
app.created_at = row.created_at;
app.created_from_origin = row.created_from_origin;
app.description = row.description;
app.godmode = as_bool(row.godmode);
app.icon = row.icon;
app.is_private = as_bool(row.is_private);
app.index_url = row.index_url;
app.maximize_on_start = as_bool(row.maximize_on_start);
app.metadata = row.metadata;
app.name = row.name;
app.protected = as_bool(row.protected);
app.stats = row.stats;
app.title = row.title;
app.uid = row.uid;
// REQURIES OTHER DATA
// app.app_owner;
// app.filetype_associations = row.filetype_associations;
// app.owner = row.owner;
app.app_owner = {
uid: row.app_owner_uid,
};
{
const owner_user = extract_from_prefix(row, 'owner_user_');
app.owner = user_to_client(owner_user);
}
try {
if ( typeof row.filetypes === 'string' ) {
app.filetype_associations = this.#parseFiletypeAssociationsJson(row.filetypes);
} else {
app.filetype_associations = this.#normalizeFiletypeAssociations(filetypesByAppId.get(row.id) ?? []);
}
} catch (e) {
throw new Error(`failed to get app filetype associations: ${e.message}`, { cause: e });
}
// REFINED BY OTHER DATA
// app.icon;
if ( svc_appIcon && (iconSize || isStoredBase64AppIcon(row)) ) {
try {
const iconPath = svc_appIcon.getAppIconPath({
appUid: row.uid,
size: iconSize,
});
if ( iconPath ) {
app.icon = iconPath;
}
} catch (e) {
svc_error?.report('AppES:read_transform', { source: e });
}
}
appAndOwnerIds.push({
app,
ownerUserId: row.owner_user_id,
});
}
// Check protected app access in parallel for faster large selections.
const allowed_apps = await Promise.all(appAndOwnerIds.map(async ({ app, ownerUserId }) => {
if ( await this.#check_protected_app_access(app, ownerUserId) ) {
return null;
}
return app;
}));
return allowed_apps.filter(Boolean);
}
async #read ({ uid, id, params = {}, backend_only_options = {} }) {
const db = this.db;
if ( uid === undefined && id === undefined ) {
throw new Error('read requires either uid or id');
}
// Build WHERE clause based on identifier type
let whereClause;
let whereValues;
let canonicalUidAliasPromise = null;
if ( uid !== undefined ) {
// Simple uid lookup
whereClause = 'apps.uid = ?';
whereValues = [uid];
canonicalUidAliasPromise = this.#readCanonicalAppUidAlias(uid);
} else if ( id !== null && typeof id === 'object' && !Array.isArray(id) ) {
// Complex id lookup (e.g., { name: 'editor' })
const { clause, values } = this.#build_complex_id_where(id);
whereClause = clause;
whereValues = values;
} else {
throw APIError.create('invalid_id', null, { id });
}
const stmt = 'SELECT apps.*, ' +
'CASE WHEN apps.icon LIKE \'data:%\' THEN 1 ELSE 0 END AS icon_is_base64, ' +
'owner_user.username AS owner_user_username, ' +
'owner_user.uuid AS owner_user_uuid, ' +
'app_owner.uid AS app_owner_uid ' +
'FROM apps ' +
'LEFT JOIN user owner_user ON apps.owner_user_id = owner_user.id ' +
'LEFT JOIN apps app_owner ON apps.app_owner = app_owner.id ' +
`WHERE ${whereClause} ` +
'LIMIT 1';
let rows = await db.read(stmt, whereValues);
if ( rows.length === 0 && canonicalUidAliasPromise ) {
const canonicalUid = await canonicalUidAliasPromise;
if (
typeof canonicalUid === 'string'
&& canonicalUid
&& canonicalUid !== uid
) {
rows = await db.read(stmt, [canonicalUid]);
}
}
if ( rows.length === 0 ) {
throw APIError.create('entity_not_found', null, {
identifier: uid || JSON.stringify(id),
});
}
const row = rows[0];
const app = {};
app.approved_for_incentive_program = as_bool(row.approved_for_incentive_program);
app.approved_for_listing = as_bool(row.approved_for_listing);
app.approved_for_opening_items = as_bool(row.approved_for_opening_items);
app.background = as_bool(row.background);
app.created_at = row.created_at;
app.created_from_origin = row.created_from_origin;
app.description = row.description;
app.godmode = as_bool(row.godmode);
app.icon = row.icon;
app.is_private = as_bool(row.is_private);
app.index_url = row.index_url;
app.maximize_on_start = as_bool(row.maximize_on_start);
app.metadata = row.metadata;
app.name = row.name;
app.protected = as_bool(row.protected);
app.stats = row.stats;
app.title = row.title;
app.uid = row.uid;
app.app_owner = {
uid: row.app_owner_uid,
};
{
const owner_user = extract_from_prefix(row, 'owner_user_');
if ( backend_only_options.no_filter_owner ) app.owner = owner_user;
else app.owner = user_to_client(owner_user);
}
let protectedAccessPromise;
try {
if ( typeof row.filetypes === 'string' ) {
app.filetype_associations = this.#parseFiletypeAssociationsJson(row.filetypes);
} else {
protectedAccessPromise = this.#check_protected_app_access(app, row.owner_user_id);
const filetypeAssociations = await this.#getFiletypeAssociationsByAppId(row.id);
app.filetype_associations = this.#normalizeFiletypeAssociations(filetypeAssociations);
}
} catch (e) {
throw new Error(`failed to get app filetype associations: ${e.message}`, { cause: e });
}
// Check protected app access as soon as dependent fields are resolved.
if ( ! protectedAccessPromise ) {
protectedAccessPromise = this.#check_protected_app_access(app, row.owner_user_id);
}
if ( await protectedAccessPromise ) {
// App should not be accessible
throw APIError.create('entity_not_found', null, {
identifier: uid || JSON.stringify(id),
});
}
const iconSize = params.icon_size;
if ( iconSize || isStoredBase64AppIcon(row) ) {
const svc_appIcon = this.context.get('services').get('app-icon');
if ( svc_appIcon ) {
try {
const iconPath = svc_appIcon.getAppIconPath({
appUid: row.uid,
size: iconSize,
});
if ( iconPath ) {
app.icon = iconPath;
}
} catch (e) {
const svc_error = this.context.get('services').get('error-service');
svc_error.report('AppES:read_transform', { source: e });
}
}
}
return app;
}
#parseFiletypeAssociationsJson (filetypes) {
return this.#normalizeFiletypeAssociations(JSON.parse(filetypes));
}
async #getFiletypeAssociationsByAppId (appId) {
if ( appId === undefined || appId === null ) return [];
const rows = await this.db.read(
'SELECT type FROM app_filetype_association WHERE app_id = ?',
[appId],
);
return rows
.map(row => row.type)
.filter(type => typeof type === 'string' || type === null);
}
#normalizeFiletypeAssociations (filetypesAsJSON) {
filetypesAsJSON = Array.isArray(filetypesAsJSON)
? filetypesAsJSON
: [];
filetypesAsJSON = filetypesAsJSON.filter(ft => ft !== null);
for ( let i = 0 ; i < filetypesAsJSON.length ; i++ ) {
if ( typeof filetypesAsJSON[i] !== 'string' ) {
throw new Error(`expected filetypesAsJSON[${i}] to be a string, got: ${filetypesAsJSON[i]}`);
}
if ( String.prototype.startsWith.call(filetypesAsJSON[i], '.') ) {
filetypesAsJSON[i] = filetypesAsJSON[i].slice(1);
}
}
return filetypesAsJSON;
}
async #getFiletypeAssociationsByAppIds (appIds) {
appIds = [...new Set(appIds.filter(appId => appId !== undefined && appId !== null))];
if ( appIds.length === 0 ) return new Map();
const filetypesByAppId = new Map();
for ( const appId of appIds ) {
filetypesByAppId.set(appId, []);
}
// SQLite has a low bind-parameter limit; chunk to avoid oversized IN lists.
const chunkSize = 500;
for ( let i = 0 ; i < appIds.length ; i += chunkSize ) {
const chunk = appIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(', ');
const rows = await this.db.read(
`SELECT app_id, type FROM app_filetype_association WHERE app_id IN (${placeholders})`,
chunk,
);
for ( const row of rows ) {
if ( ! filetypesByAppId.has(row.app_id) ) {
filetypesByAppId.set(row.app_id, []);
}
filetypesByAppId.get(row.app_id).push(row.type);
}
}
return filetypesByAppId;
}
async #create ({ object, options }) {
// Only UserActorType and AppUnderUserActorType are allowed to do this
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType || actor.type instanceof AppUnderUserActorType) ) {
throw APIError.create('forbidden');
}
const user = actor.type.user;
// Remove protected/read_only fields from the input (ValidationES behavior)
{
object = { ...object };
for ( const field of this.constructor.PROTECTED_FIELDS ) {
delete object[field];
}
for ( const field of this.constructor.READ_ONLY_FIELDS ) {
delete object[field];
}
}
// Validate required fields
{
if ( object.name === undefined ) {
throw APIError.create('field_missing', null, { key: 'name' });
}
if ( object.title === undefined ) {
throw APIError.create('field_missing', null, { key: 'title' });
}
if ( object.index_url === undefined ) {
throw APIError.create('field_missing', null, { key: 'index_url' });
}
}
// Validate fields
{
validate_string(object.name, {
key: 'name',
maxlen: config.app_name_max_length,
regex: config.app_name_regex,
});
validate_string(object.title, {
key: 'title',
maxlen: config.app_title_max_length,
});
if ( object.description !== undefined && object.description !== null ) {
validate_string(object.description, {
key: 'description',
maxlen: 7000,
});
}
if ( object.icon !== undefined && object.icon !== null ) {
if ( typeof object.icon === 'string' ) {
object.icon = normalizeRawBase64ImageString(object.icon);
object.icon = migrateRelativeAppIconEndpointUrl(object.icon);
}
if ( typeof object.icon !== 'string' ) {
throw APIError.create('field_invalid', null, { key: 'icon' });
}
object.icon = object.icon.trim();
if ( ! object.icon ) {
// Empty icon is allowed to clear current icon.
} else if ( object.icon.startsWith('data:') ) {
validate_image_base64(object.icon, { key: 'icon' });
} else if ( ! isAllowedAppIconEndpointUrl(object.icon) ) {
throw APIError.create('field_invalid', null, { key: 'icon' });
}
}
validate_url(object.index_url, {
key: 'index_url',
maxlen: 3000,
});
if ( object.maximize_on_start !== undefined ) {
object.maximize_on_start = as_bool(object.maximize_on_start);
}
if ( object.background !== undefined ) {
object.background = as_bool(object.background);
}
if ( object.metadata !== undefined && object.metadata !== null ) {
validate_json(object.metadata, { key: 'metadata' });
}
if ( object.filetype_associations !== undefined ) {
validate_array_of_strings(object.filetype_associations, {
key: 'filetype_associations',
});
}
}
// Ensure puter.site subdomain is owned by user (if index_url uses it)
await this.#ensure_puter_site_subdomain_is_owned(object.index_url, user);
const joinedApp = await this.#maybeJoinOwnedHostedIndexUrlAppOnCreate({
object,
options,
user,
});
if ( joinedApp ) {
return joinedApp;
}
await this.#ensureIndexUrlNotAlreadyInUse({
indexUrl: object.index_url,
});
// Handle app name conflicts (AppES behavior)
if ( await app_name_exists(object.name) ) {
if ( options?.dedupe_name ) {
const base = object.name;
let number = 1;
while ( await app_name_exists(`${base}-${number}`) ) {
number++;
}
object.name = `${base}-${number}`;
} else {
throw APIError.create('app_name_already_in_use', null, {
name: object.name,
});
}
}
// Generate UID for the new app (puter-uuid format: app-{uuid})
const uid = `app-${uuidv4()}`;
// Determine app_owner if actor is AppUnderUserActorType (SetOwnerES behavior)
let app_owner_id = null;
if ( actor.type instanceof AppUnderUserActorType ) {
app_owner_id = actor.type.app.id;
}
// Execute SQL INSERT
const insert_id = await this.#execute_insert(object, uid, user.id, app_owner_id);
// Handle file type associations
if ( object.filetype_associations ) {
await this.#update_filetype_associations(insert_id, object.filetype_associations);
}
// Emit icon event if icon is set
if ( object.icon ) {
const svc_event = this.services.get('event');
const event = {
app_uid: uid,
data_url: object.icon,
url: '',
};
await svc_event.emit('app.new-icon', event);
if ( typeof event.url === 'string' && event.url ) {
this.db_write.write(
'UPDATE apps SET icon = ? WHERE uid = ? LIMIT 1',
[event.url, uid],
);
}
}
// Return the created app
return await this.#read({ uid });
}
async #execute_insert (object, uid, owner_user_id, app_owner_id) {
const columns = ['uid', 'owner_user_id'];
const values = [uid, owner_user_id];
if ( app_owner_id !== null ) {
columns.push('app_owner');
values.push(app_owner_id);
}
const sql_column_map = {
name: 'name',
title: 'title',
description: 'description',
icon: 'icon',
index_url: 'index_url',
maximize_on_start: 'maximize_on_start',
background: 'background',
metadata: 'metadata',
};
for ( const [field, column] of Object.entries(sql_column_map) ) {
if ( object[field] === undefined ) continue;
let value = object[field];
// Handle JSON fields
if ( field === 'metadata' && value !== null ) {
value = JSON.stringify(value);
}
// Handle boolean fields
if ( field === 'maximize_on_start' || field === 'background' ) {
value = value ? 1 : 0;
}
columns.push(column);
values.push(value);
}
const placeholders = columns.map(() => '?').join(', ');
const stmt = `INSERT INTO apps (${columns.join(', ')}) VALUES (${placeholders})`;
const result = await this.db_write.write(stmt, values);
return result.insertId;
}
async #delete ({ uid, id }) {
// Only UserActorType and AppUnderUserActorType are allowed to do this
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType || actor.type instanceof AppUnderUserActorType) ) {
throw APIError.create('forbidden');
}
// Read the existing app
const old_app = await this.#read({
uid,
id,
backend_only_options: { no_filter_owner: true },
});
if ( ! old_app ) {
throw APIError.create('entity_not_found', null, {
identifier: uid || JSON.stringify(id),
});
}
// Check owner permission (WriteByOwnerOnlyES behavior)
await this.#check_owner_permission(old_app);
// If actor is AppUnderUserActorType, check app_owner (AppLimitedES behavior)
if ( actor.type instanceof AppUnderUserActorType ) {
await this.#check_app_owner_permission(old_app, actor);
}
// Call app-information service to perform the deletion (AppES behavior)
const svc_appInformation = this.services.get('app-information');
await svc_appInformation.delete_app(old_app.uid);
return { success: true, uid: old_app.uid };
}
async #check_app_owner_permission (old_app, actor) {
// Check if app has write permission to all user's apps
const svc_permission = this.services.get('permission');
const user = actor.type.user;
const perm = `es:app:${user.uuid}:write`;
const can_write_any = await svc_permission.check(actor, perm);
if ( can_write_any ) {
return;
}
// Otherwise verify the app owns this entity
const app = actor.type.app;
const app_owner = old_app.app_owner;
const app_owner_uid = app_owner?.uid;
if ( !app_owner_uid || app_owner_uid !== app.uid ) {
throw APIError.create('forbidden');
}
}
async #update ({ object, id, options }) {
const old_app = await this.#read({
uid: object.uid,
id,
backend_only_options: { no_filter_owner: true },
});
if ( ! old_app ) {
throw APIError.create('entity_not_found', null, {
identifier: object.uid || JSON.stringify(id),
});
}
// Only UserActorType and AppUnderUserActorType are allowed to do this
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType || actor.type instanceof AppUnderUserActorType) ) {
throw APIError.create('forbidden');
}
// Check owner permission (WriteByOwnerOnlyES behavior)
await this.#check_owner_permission(old_app);
// If actor is AppUnderUserActorType, check app_owner (AppLimitedES behavior)
if ( actor.type instanceof AppUnderUserActorType ) {
await this.#check_app_owner_permission(old_app, actor);
}
// Remove protected/read_only fields from the update (ValidationES behavior)
{
object = { ...object };
for ( const field of this.constructor.PROTECTED_FIELDS ) {
delete object[field];
}
for ( const field of this.constructor.READ_ONLY_FIELDS ) {
delete object[field];
}
}
// Validate fields
{
if ( object.name !== undefined ) {
validate_string(object.name, {
key: 'name',
maxlen: config.app_name_max_length,
regex: config.app_name_regex,
});
}
if ( object.title !== undefined ) {
validate_string(object.title, {
key: 'title',
maxlen: config.app_title_max_length,
});
}
if ( object.description !== undefined && object.description !== null ) {
validate_string(object.description, {
key: 'description',
maxlen: 7000,
});
}
if ( object.icon !== undefined && object.icon !== null ) {
if ( typeof object.icon === 'string' ) {
object.icon = normalizeRawBase64ImageString(object.icon);
object.icon = migrateRelativeAppIconEndpointUrl(object.icon);
}
if ( typeof object.icon !== 'string' ) {
throw APIError.create('field_invalid', null, { key: 'icon' });
}
object.icon = object.icon.trim();
if ( ! object.icon ) {
// Empty icon is allowed to clear current icon.
} else if ( object.icon.startsWith('data:') ) {
validate_image_base64(object.icon, { key: 'icon' });
} else if ( ! isAllowedAppIconEndpointUrl(object.icon) ) {
throw APIError.create('field_invalid', null, { key: 'icon' });
}
}
if ( object.index_url !== undefined ) {
validate_url(object.index_url, {
key: 'index_url',
maxlen: 3000,
});
}
// Flag type - adapt values using as_bool
if ( object.maximize_on_start !== undefined ) {
object.maximize_on_start = as_bool(object.maximize_on_start);
}
if ( object.background !== undefined ) {
object.background = as_bool(object.background);
}
if ( object.metadata !== undefined && object.metadata !== null ) {
validate_json(object.metadata, { key: 'metadata' });
}
if ( object.filetype_associations !== undefined ) {
validate_array_of_strings(object.filetype_associations, {
key: 'filetype_associations',
});
}
}
// Handle app-specific logic (AppES behavior)
const user = actor.type.user;
const oldAppId = await this.#resolveAppId(old_app);
// Ensure puter.site subdomain is owned by user (if index_url changed)
if ( object.index_url && object.index_url !== old_app.index_url ) {
await this.#ensure_puter_site_subdomain_is_owned(object.index_url, user);
const joinedApp = await this.#maybeJoinOwnedHostedIndexUrlAppOnCreate({
object,
options,
user,
excludeAppId: oldAppId,
});
if ( joinedApp ) {
return joinedApp;
}
await this.#ensureIndexUrlNotAlreadyInUse({
indexUrl: object.index_url,
excludeAppId: oldAppId,
});
}
// Handle app name conflicts
if ( object.name !== undefined ) {
await this.#handle_name_conflict(object, old_app, options);
}
// Build and execute SQL UPDATE
const { insert_id } = await this.#execute_update(object, old_app);
// Handle file type associations
if ( object.filetype_associations !== undefined ) {
await this.#update_filetype_associations(insert_id, object.filetype_associations);
}
// Emit events for icon/name or app changes
await this.#emit_change_events(object, old_app);
// Return the updated app (re-fetch for client-safe output)
// TODO: optimize this
return await this.#read({ uid: old_app.uid });
}
async #resolveAppId (app) {
const appId = Number(app?.id);
if ( Number.isInteger(appId) && appId > 0 ) return appId;
if ( typeof app?.uid !== 'string' || !app.uid ) return undefined;
const rows = await this.db.read(
'SELECT id FROM apps WHERE uid = ? LIMIT 1',
[app.uid],
);
const resolvedId = Number(rows?.[0]?.id);
if ( Number.isInteger(resolvedId) && resolvedId > 0 ) return resolvedId;
return undefined;
}
async #check_owner_permission (old_app) {
const svc_permission = this.services.get('permission');
const actor = Context.get('actor');
// Check if user has system-wide write permission
{
// We need to fix eslint rule for multi-line calls
const has_permission_to_write_all = await svc_permission.check(
actor,
this.constructor.WRITE_ALL_OWNER_PERMISSION,
);
if ( has_permission_to_write_all ) {
return;
}
}
// Check if user owns the app
{
const user = Context.get('user');
if ( ! old_app.owner ) {
throw APIError.create('forbidden');
}
if ( user.id !== old_app.owner.id ) {
throw APIError.create('forbidden');
}
}
}
/**
* Resolves an app's subdomain to its puter.site root_dir_id.
* Tries associated_app_id first, then falls back to index_url-based lookup.
* @param {Object} app - App object with id, index_url, uid
* @returns {Promise} root_dir_id
* @throws {APIError} entity_not_found if the app has no subdomain / root directory
*/
async getAppRootDirId (app) {
const db_sites = this.services.get('database').get(DB_READ, 'sites');
const rows = await db_sites.read(
'SELECT root_dir_id FROM subdomains WHERE associated_app_id = ? AND root_dir_id IS NOT NULL LIMIT 1',
[app.id],
);
if ( rows?.[0]?.root_dir_id != null ) {
return rows[0].root_dir_id;
}
let hostname;
try {
hostname = (new URL(app.index_url)).hostname.toLowerCase();
} catch {
throw APIError.create('entity_not_found', null, { identifier: `app ${app.uid} root directory` });
}
const hosting_domain = config.static_hosting_domain?.toLowerCase();
if ( !hosting_domain || !hostname.endsWith(`.${hosting_domain}`) ) {
throw APIError.create('entity_not_found', null, { identifier: `app ${app.uid} root directory` });
}
const subdomain = hostname.slice(0, hostname.length - hosting_domain.length - 1);
const site = await this.services.get('puter-site').get_subdomain(subdomain, { is_custom_domain: false });
if ( ! site?.root_dir_id ) {
throw APIError.create('entity_not_found', null, { identifier: `app ${app.uid} root directory` });
}
return site.root_dir_id;
}
async #ensure_puter_site_subdomain_is_owned (index_url, user) {
if ( ! user ) return;
const subdomain = this.#extractPuterHostedSubdomain(index_url);
if ( ! subdomain ) return;
const svc_puterSite = this.services.get('puter-site');
const site = await svc_puterSite.get_subdomain(subdomain, { is_custom_domain: false });
if ( !site || site.user_id !== user.id ) {
throw APIError.create('subdomain_not_owned', null, { subdomain });
}
}
#normalizeConfiguredHostedDomain (domainValue) {
if ( typeof domainValue !== 'string' ) return null;
const normalizedDomain = domainValue.trim().toLowerCase().replace(/^\./, '');
if ( ! normalizedDomain ) return null;
return normalizedDomain.split(':')[0] || null;
}
#getPuterHostedDomains () {
const domains = new Set();
for ( const configuredDomain of [
config.static_hosting_domain,
config.static_hosting_domain_alt,
config.private_app_hosting_domain,
config.private_app_hosting_domain_alt,
] ) {
const normalizedConfiguredDomain = this.#normalizeConfiguredHostedDomain(configuredDomain);
if ( normalizedConfiguredDomain ) {
domains.add(normalizedConfiguredDomain);
}
}
return [...domains];
}
#extractPuterHostedSubdomain (indexUrl) {
if ( typeof indexUrl !== 'string' || !indexUrl ) return null;
let hostname;
try {
hostname = (new URL(indexUrl)).hostname.toLowerCase();
} catch {
return null;
}
const hostedDomains = this.#getPuterHostedDomains();
hostedDomains.sort((domainA, domainB) => domainB.length - domainA.length);
for ( const hostedDomain of hostedDomains ) {
const suffix = `.${hostedDomain}`;
if ( hostname.endsWith(suffix) ) {
const subdomain = hostname.slice(0, hostname.length - suffix.length);
return subdomain || null;
}
}
return null;
}
#isPuterHostedIndexUrl (indexUrl) {
return !!this.#extractPuterHostedSubdomain(indexUrl);
}
#buildEquivalentIndexUrlCandidates (indexUrl) {
if ( typeof indexUrl !== 'string' || !indexUrl.trim() ) {
return [];
}
try {
const parsedIndexUrl = new URL(indexUrl);
const origin = `${parsedIndexUrl.protocol}//${parsedIndexUrl.host.toLowerCase()}`;
const pathname = parsedIndexUrl.pathname || '/';
const candidates = new Set();
if ( pathname === '/' || pathname.toLowerCase() === '/index.html' ) {
candidates.add(origin);
candidates.add(`${origin}/`);
candidates.add(`${origin}/index.html`);
} else {
const normalizedPath = pathname.endsWith('/')
? pathname.slice(0, -1)
: pathname;
candidates.add(`${origin}${normalizedPath}`);
candidates.add(`${origin}${normalizedPath}/`);
}
return [...candidates];
} catch {
return [indexUrl.trim()];
}
}
async #findIndexUrlConflictRow ({ indexUrl, excludeAppId } = {}) {
if ( ! this.#isPuterHostedIndexUrl(indexUrl) ) {
return null;
}
const indexUrlCandidates = this.#buildEquivalentIndexUrlCandidates(indexUrl);
if ( indexUrlCandidates.length === 0 ) return null;
if ( hasIndexUrlUniquenessExemption(indexUrlCandidates) ) return null;
const placeholders = indexUrlCandidates.map(() => '?').join(', ');
const parameters = [...indexUrlCandidates];
let query = `SELECT id, uid, owner_user_id, index_url FROM apps WHERE index_url IN (${placeholders})`;
if ( Number.isInteger(excludeAppId) && excludeAppId > 0 ) {
query += ' AND id != ?';
parameters.push(excludeAppId);
}
query += ' ORDER BY timestamp ASC, id ASC LIMIT 1';
const rows = await this.db.read(query, parameters);
const conflictRow = rows.find(row => {
if (
Number.isInteger(excludeAppId)
&& excludeAppId > 0
&& Number(row?.id) === excludeAppId
) {
return false;
}
if ( typeof row?.index_url === 'string' ) {
return indexUrlCandidates.includes(row.index_url);
}
return true;
});
return conflictRow || null;
}
async #ensureIndexUrlNotAlreadyInUse ({ indexUrl, excludeAppId } = {}) {
const conflictRow = await this.#findIndexUrlConflictRow({ indexUrl, excludeAppId });
if ( conflictRow ) {
throw APIError.create('app_index_url_already_in_use', null, {
index_url: indexUrl,
app_uid: conflictRow.uid,
});
}
}
async #claimAppOwnershipByIdForUser ({ appId, userId }) {
if ( !Number.isInteger(appId) || appId <= 0 ) return;
if ( !Number.isInteger(userId) || userId <= 0 ) return;
await this.db_write.write(
'UPDATE apps SET owner_user_id = ? WHERE id = ? AND owner_user_id IS NULL',
[userId, appId],
);
}
#buildCanonicalAppUidAliasKey (oldAppUid) {
return `${APP_UID_ALIAS_KEY_PREFIX}:${oldAppUid}`;
}
#buildCanonicalAppUidAliasReverseKey (canonicalAppUid) {
return `${APP_UID_ALIAS_REVERSE_KEY_PREFIX}:${canonicalAppUid}`;
}
#normalizeCanonicalAliasUidList (value) {
if ( ! Array.isArray(value) ) return [];
const normalizedList = [];
const seen = new Set();
for ( const item of value ) {
if ( typeof item !== 'string' || !item ) continue;
if ( seen.has(item) ) continue;
seen.add(item);
normalizedList.push(item);
}
return normalizedList;
}
async #readCanonicalAppUidAlias (oldAppUid) {
if ( typeof oldAppUid !== 'string' || !oldAppUid ) return null;
const kvStore = this.services.get('puter-kvstore');
const suService = this.services.get('su');
if ( !kvStore || typeof kvStore.get !== 'function' ) return null;
if ( !suService || typeof suService.sudo !== 'function' ) return null;
const key = this.#buildCanonicalAppUidAliasKey(oldAppUid);
try {
const canonicalAppUid = await suService.sudo(() => kvStore.get({ key }));
if ( typeof canonicalAppUid === 'string' && canonicalAppUid ) {
return canonicalAppUid;
}
} catch {
// Alias reads are best-effort.
}
return null;
}
async #writeCanonicalAppUidAlias ({ oldAppUid, canonicalAppUid }) {
if ( typeof oldAppUid !== 'string' || !oldAppUid ) return;
if ( typeof canonicalAppUid !== 'string' || !canonicalAppUid ) return;
if ( oldAppUid === canonicalAppUid ) return;
const kvStore = this.services.get('puter-kvstore');
const suService = this.services.get('su');
if ( !kvStore || typeof kvStore.set !== 'function' ) return;
if ( !suService || typeof suService.sudo !== 'function' ) return;
const key = this.#buildCanonicalAppUidAliasKey(oldAppUid);
const reverseKey = this.#buildCanonicalAppUidAliasReverseKey(canonicalAppUid);
const expireAt = Math.floor(Date.now() / 1000) + APP_UID_ALIAS_TTL_SECONDS;
try {
await suService.sudo(async () => {
const reverseValue = await kvStore.get({ key: reverseKey });
const reverseAliases = this.#normalizeCanonicalAliasUidList(reverseValue);
if ( ! reverseAliases.includes(oldAppUid) ) {
reverseAliases.push(oldAppUid);
}
await kvStore.set({
key,
value: canonicalAppUid,
expireAt,
});
await kvStore.set({
key: reverseKey,
value: reverseAliases,
expireAt,
});
});
} catch {
// Alias writes are best-effort.
}
}
async #maybeJoinOwnedHostedIndexUrlAppOnCreate ({
object,
options,
user,
excludeAppId,
} = {}) {
const indexUrl = object?.index_url;
const sourceAppUid = object?.uid;
if ( ! this.#isPuterHostedIndexUrl(indexUrl) ) {
return null;
}
const conflictRow = await this.#findIndexUrlConflictRow({
indexUrl,
excludeAppId,
});
if ( ! conflictRow ) {
return null;
}
const conflictOwnerUserId = Number(conflictRow.owner_user_id);
if (
Number.isInteger(conflictOwnerUserId)
&& conflictOwnerUserId > 0
&& conflictOwnerUserId !== user.id
) {
throw APIError.create('app_index_url_already_in_use', null, {
index_url: indexUrl,
app_uid: conflictRow.uid,
});
}
if ( !Number.isInteger(conflictOwnerUserId) || conflictOwnerUserId <= 0 ) {
await this.#claimAppOwnershipByIdForUser({
appId: conflictRow.id,
userId: user.id,
});
}
const appToJoin = await this.#read({
uid: conflictRow.uid,
backend_only_options: {
no_filter_owner: true,
},
});
if ( !appToJoin || appToJoin.uid !== conflictRow.uid ) {
throw APIError.create('app_index_url_already_in_use', null, {
index_url: indexUrl,
app_uid: conflictRow.uid,
});
}
const appToJoinOwnerId = Number(appToJoin.owner?.id);
if ( !Number.isInteger(appToJoinOwnerId) || appToJoinOwnerId !== user.id ) {
throw APIError.create('app_index_url_already_in_use', null, {
index_url: indexUrl,
app_uid: conflictRow.uid,
});
}
if (
Number.isInteger(conflictOwnerUserId)
&& conflictOwnerUserId === user.id
&& !this.#isOriginBootstrapApp(appToJoin)
) {
// Prevent merging arbitrary same-owner apps; only allow the
// auto-created origin bootstrap app to be absorbed.
throw APIError.create('app_index_url_already_in_use', null, {
index_url: indexUrl,
app_uid: conflictRow.uid,
});
}
const joinedObject = {
...object,
uid: appToJoin.uid,
};
const requestedJoinedName = (
typeof joinedObject.name === 'string'
? joinedObject.name.trim()
: ''
) || null;
const shouldReapplyRequestedNameAfterMerge = (
!!object?.uid
&& !!requestedJoinedName
);
if ( object?.uid && joinedObject.name !== undefined ) {
delete joinedObject.name;
}
let joinedApp = await this.#update({
object: joinedObject,
options,
});
if ( sourceAppUid && sourceAppUid !== appToJoin.uid ) {
await this.#writeCanonicalAppUidAlias({
oldAppUid: sourceAppUid,
canonicalAppUid: appToJoin.uid,
});
const svc_appInformation = this.services.get('app-information');
if ( svc_appInformation?.delete_app ) {
await svc_appInformation.delete_app(sourceAppUid, undefined, {
preserveCanonicalUidAlias: true,
});
}
}
if ( shouldReapplyRequestedNameAfterMerge ) {
joinedApp = await this.#update({
object: {
uid: appToJoin.uid,
name: requestedJoinedName,
},
options,
});
}
return joinedApp;
}
#isOriginBootstrapApp (app) {
if ( !app || typeof app !== 'object' ) return false;
if ( typeof app.uid !== 'string' || !app.uid ) return false;
if ( app.name !== app.uid ) return false;
if ( app.title !== app.uid ) return false;
if ( typeof app.description !== 'string' ) return false;
return app.description.startsWith('App created from origin ');
}
async #handle_name_conflict (object, old_app, options) {
const new_name = object.name;
const old_name = old_app.name;
// If the name hasn't changed, nothing to do
if ( new_name === old_name ) {
delete object.name;
return;
}
// Check if the name is taken
if ( await app_name_exists(new_name) ) {
if ( options?.dedupe_name ) {
// Auto-deduplicate the name
let number = 1;
while ( await app_name_exists(`${new_name}-${number}`) ) {
number++;
}
object.name = `${new_name}-${number}`;
} else {
// Check if this is an old name of the same app
const svc_oldAppName = this.services.get('old-app-name');
const name_info = await svc_oldAppName.check_app_name(new_name);
if ( !name_info || name_info.app_uid !== old_app.uid ) {
throw APIError.create('app_name_already_in_use', null, {
name: new_name,
});
}
// Remove the old name from the old-app-name service
await svc_oldAppName.remove_name(name_info.id);
}
}
}
async #execute_update (object, old_app) {
// Map object fields to SQL columns
const sql_column_map = {
name: 'name',
title: 'title',
description: 'description',
icon: 'icon',
index_url: 'index_url',
maximize_on_start: 'maximize_on_start',
background: 'background',
metadata: 'metadata',
};
const set_clauses = [];
const values = [];
for ( const [field, column] of Object.entries(sql_column_map) ) {
if ( object[field] === undefined ) continue;
let value = object[field];
// Handle JSON fields
if ( field === 'metadata' && value !== null ) {
value = JSON.stringify(value);
}
// Handle boolean fields
if ( field === 'maximize_on_start' || field === 'background' ) {
value = value ? 1 : 0;
}
set_clauses.push(`${column} = ?`);
values.push(value);
}
if ( set_clauses.length > 0 ) {
values.push(old_app.uid);
const stmt = `UPDATE apps SET ${set_clauses.join(', ')} WHERE uid = ? LIMIT 1`;
await this.db_write.write(stmt, values);
}
// Fetch the internal ID
const rows = await this.db.read(
'SELECT id FROM apps WHERE uid = ?',
[old_app.uid],
);
return { insert_id: rows[0]?.id };
}
async #update_filetype_associations (app_id, filetype_associations) {
const oldAssociations = await this.db.read(
'SELECT type FROM app_filetype_association WHERE app_id = ?',
[app_id],
);
const normalizedOld = oldAssociations
.map(row => String(row.type ?? '').trim().toLowerCase().replace(/^\./, ''))
.filter(Boolean);
const normalizedNew = (filetype_associations ?? [])
.map(ft => String(ft).trim().toLowerCase().replace(/^\./, ''))
.filter(Boolean);
// Remove old file associations
await this.db_write.write(
'DELETE FROM app_filetype_association WHERE app_id = ?',
[app_id],
);
// Add new file associations
if ( ! normalizedNew.length ) {
const affectedExtensions = new Set(normalizedOld);
if ( affectedExtensions.size ) {
await deleteRedisKeys(Array.from(affectedExtensions)
.map(ext => AppRedisCacheSpace.associationAppsKey(ext)));
}
return;
}
const stmt =
`INSERT INTO app_filetype_association (app_id, type) VALUES ${
normalizedNew.map(() => '(?, ?)').join(', ')}`;
const values = normalizedNew.flatMap(ft => [app_id, ft]);
await this.db_write.write(stmt, values);
const affectedExtensions = new Set([...normalizedOld, ...normalizedNew]);
if ( affectedExtensions.size ) {
await deleteRedisKeys(Array.from(affectedExtensions)
.map(ext => AppRedisCacheSpace.associationAppsKey(ext)));
}
}
async #emit_change_events (object, old_app) {
const svc_event = this.services.get('event');
const app = {
...old_app,
...object,
uid: old_app.uid,
};
await svc_event.emit('app.changed', {
app_uid: old_app.uid,
action: 'updated',
app,
old_app,
});
// Emit icon change event
if ( object.icon !== undefined && object.icon !== old_app.icon ) {
const event = {
app_uid: old_app.uid,
data_url: object.icon,
};
await svc_event.emit('app.new-icon', event);
if ( typeof event.url === 'string' && event.url ) {
await this.db_write.write(
'UPDATE apps SET icon = ? WHERE uid = ? LIMIT 1',
[event.url, old_app.uid],
);
}
}
// Emit name change event
if ( object.name !== undefined && object.name !== old_app.name ) {
const event = {
app_uid: old_app.uid,
new_name: object.name,
old_name: old_app.name,
};
await svc_event.emit('app.rename', event);
}
}
#build_complex_id_where (id) {
const id_keys = Object.keys(id);
id_keys.sort();
// 1. Validate the identifier key from `id`
const redundant_identifiers = this.constructor.REDUNDANT_IDENTIFIERS;
let match_found = false;
for ( let key_set of redundant_identifiers ) {
key_set = Array.isArray(key_set) ? key_set : [key_set];
const sorted_key_set = [...key_set].sort();
// Check if id_keys matches this key_set exactly
if ( id_keys.length === sorted_key_set.length &&
id_keys.every((k, i) => k === sorted_key_set[i]) ) {
match_found = true;
break;
}
}
if ( ! match_found ) {
throw new Error(`Invalid complex id keys: ${id_keys.join(', ')}. ` +
`Allowed: ${redundant_identifiers.join(', ')}`);
}
// 2. Build the SQL string for the predicate
const conditions = [];
const values = [];
for ( const key of id_keys ) {
conditions.push(`apps.${key} = ?`);
values.push(id[key]);
}
return {
clause: conditions.join(' AND '),
values,
};
}
/**
* Checks if a protected app should be filtered out (not accessible to the current actor).
* Returns true if the app should be filtered out, false if it's accessible.
*
* @param {Object} app - The app object with protected, uid, and owner fields
* @param {number} owner_user_id - The database ID of the app owner (for accurate comparison)
* @returns {Promise} true if app should be filtered out, false if accessible
*/
async #check_protected_app_access (app, owner_user_id) {
// If it's not a protected app, no worries - allow it
if ( ! app.protected ) {
return false;
}
const actor = Context.get('actor');
const services = this.services;
// If actor is this app itself, allow it
if (
actor.type instanceof AppUnderUserActorType &&
app.uid === actor.type.app.uid
) {
return false;
}
// If actor is owner of this app, allow it
// Compare using owner_user_id from database for accuracy
if (
actor.type instanceof UserActorType &&
owner_user_id &&
owner_user_id === actor.type.user.id
) {
return false;
}
// Now we need to check for permission
const app_uid = app.uid;
const svc_permission = services.get('permission');
const permission_to_check = `app:uid#${app_uid}:access`;
// If they have permission, allow it
if ( await svc_permission.check(actor, permission_to_check) ) {
return false;
}
// No access - filter it out
return true;
}
}
================================================
FILE: src/backend/src/modules/data-access/AppService.test.js
================================================
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AppService from './AppService.js';
// Mock the Context module
vi.mock('../../util/context.js', () => ({
Context: {
get: vi.fn(),
},
}));
// Mock the helpers module
vi.mock('../../helpers.js', () => ({
app_name_exists: vi.fn(),
}));
// Mock the Actor module
vi.mock('../../services/auth/Actor.js', () => ({
UserActorType: class UserActorType {
},
AppUnderUserActorType: class AppUnderUserActorType {
},
}));
// Mock the validation module
vi.mock('./lib/validation.js', () => ({
validate_string: vi.fn(),
validate_url: vi.fn(),
validate_image_base64: vi.fn(),
validate_json: vi.fn(),
validate_array_of_strings: vi.fn(),
}));
// Mock config
vi.mock('../../config.js', () => ({
default: {
app_name_max_length: 100,
app_name_regex: /^[a-z0-9-]+$/,
app_title_max_length: 200,
static_hosting_domain: 'puter.site',
static_hosting_domain_alt: 'puter.host',
private_app_hosting_domain: 'puter.app',
private_app_hosting_domain_alt: 'puter.dev',
origin: 'https://puter.localhost',
api_base_url: 'https://api.puter.localhost',
},
}));
import { app_name_exists } from '../../helpers.js';
import { AppUnderUserActorType, UserActorType } from '../../services/auth/Actor.js';
import { Context } from '../../util/context.js';
import {
validate_string,
validate_url,
} from './lib/validation.js';
describe('AppService', () => {
let appService;
let mockDb;
let mockDbWrite;
let mockServices;
let mockEventService;
let mockPermissionService;
let mockPuterSiteService;
let mockOldAppNameService;
let mockAppInformationService;
let mockKvStoreService;
let mockSuService;
// Helper to create a mock database row
const createMockAppRow = (overrides = {}) => ({
id: 1,
uid: 'app-uid-123',
name: 'test-app',
title: 'Test App',
description: 'A test application',
icon: 'icon.png',
index_url: 'https://example.com/app',
created_at: '2024-01-01T00:00:00Z',
created_from_origin: 'localhost',
metadata: '{}',
stats: '{}',
approved_for_incentive_program: 0,
approved_for_listing: 1,
approved_for_opening_items: 1,
background: 0,
godmode: 0,
is_private: 0,
maximize_on_start: 0,
protected: 0,
owner_user_id: 1,
owner_user_username: 'testuser',
owner_user_uuid: 'user-uuid-456',
app_owner_uid: 'owner-app-uid-789',
filetypes: '["txt", "doc"]',
...overrides,
});
// Helper to create a mock actor
const createMockUserActor = (userId = 1) => ({
type: Object.assign(new UserActorType(), { user: { id: userId } }),
});
const createMockAppUnderUserActor = (userId = 1, appId = 100) => ({
type: Object.assign(new AppUnderUserActorType(), {
user: { id: userId },
app: { id: appId, uid: 'creator-app-uid' },
}),
});
// Helper to setup Context.get mock for create/update tests
const setupContextForWrite = (actor, user = { id: 1 }) => {
Context.get.mockImplementation((key) => {
if ( key === 'actor' ) return actor;
if ( key === 'user' ) return user;
return null;
});
};
beforeEach(() => {
// Reset mocks
vi.clearAllMocks();
// Reset helper mocks
app_name_exists.mockResolvedValue(false);
// Mock database (read)
mockDb = {
read: vi.fn(),
case: vi.fn().mockImplementation(({ sqlite }) => sqlite),
};
// Mock database (write)
mockDbWrite = {
write: vi.fn().mockResolvedValue({ insertId: 1 }),
};
// Mock event service
mockEventService = {
emit: vi.fn().mockResolvedValue(undefined),
};
// Mock permission service
mockPermissionService = {
check: vi.fn().mockResolvedValue(false),
scan: vi.fn().mockResolvedValue([]),
};
// Mock puter-site service
mockPuterSiteService = {
get_subdomain: vi.fn().mockResolvedValue(null),
};
// Mock old-app-name service
mockOldAppNameService = {
check_app_name: vi.fn().mockResolvedValue(null),
remove_name: vi.fn().mockResolvedValue(undefined),
};
// Mock app-information service
mockAppInformationService = {
delete_app: vi.fn().mockResolvedValue(undefined),
};
mockKvStoreService = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(true),
};
mockSuService = {
sudo: vi.fn(async (actorOrCallback, maybeCallback) => {
const callback = maybeCallback || actorOrCallback;
return await callback();
}),
};
// Mock services
mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'database' ) {
return {
get: vi.fn().mockImplementation((mode) => {
if ( mode === 'write' ) return mockDbWrite;
return mockDb;
}),
};
}
if ( serviceName === 'event' ) return mockEventService;
if ( serviceName === 'permission' ) return mockPermissionService;
if ( serviceName === 'puter-site' ) return mockPuterSiteService;
if ( serviceName === 'old-app-name' ) return mockOldAppNameService;
if ( serviceName === 'app-information' ) return mockAppInformationService;
if ( serviceName === 'puter-kvstore' ) return mockKvStoreService;
if ( serviceName === 'su' ) return mockSuService;
return null;
}),
};
// Create AppService instance
appService = new AppService({
services: mockServices,
config: {},
name: 'app-service',
args: {},
context: {
get: vi.fn().mockReturnValue(mockServices),
},
});
// Manually call _init to set up the service
appService.repository = {};
appService.db = mockDb;
appService.db_write = mockDbWrite;
});
describe('#read', () => {
it('should read an app by uid', async () => {
const mockRow = createMockAppRow();
mockDb.read.mockResolvedValueOnce([mockRow]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, { uid: 'app-uid-123' });
expect(mockDb.read).toHaveBeenCalledTimes(1);
expect(mockDb.read).toHaveBeenNthCalledWith(
1,
expect.stringContaining('WHERE apps.uid = ?'),
['app-uid-123'],
);
expect(result).toBeDefined();
expect(result.uid).toBe('app-uid-123');
expect(result.name).toBe('test-app');
expect(result.title).toBe('Test App');
});
it('should read an app by complex id (name)', async () => {
const mockRow = createMockAppRow();
mockDb.read.mockResolvedValueOnce([mockRow]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, { id: { name: 'test-app' } });
expect(mockDb.read).toHaveBeenCalledTimes(1);
expect(mockDb.read).toHaveBeenNthCalledWith(
1,
expect.stringContaining('WHERE apps.name = ?'),
['test-app'],
);
expect(result).toBeDefined();
expect(result.name).toBe('test-app');
});
it('should throw entity_not_found when no app is found', async () => {
mockDb.read.mockResolvedValue([]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.read.call(appService, { uid: 'nonexistent-uid' })).rejects.toMatchObject({
fields: { code: 'entity_not_found' },
});
});
it('should resolve app by canonical uid alias when old uid is missing', async () => {
const canonicalRow = createMockAppRow({
uid: 'app-canonical-uid-123',
});
mockDb.read
.mockResolvedValueOnce([])
.mockResolvedValueOnce([canonicalRow]);
mockKvStoreService.get.mockResolvedValue('app-canonical-uid-123');
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, { uid: 'app-old-uid-123' });
expect(result.uid).toBe('app-canonical-uid-123');
expect(mockSuService.sudo).toHaveBeenCalled();
expect(mockKvStoreService.get).toHaveBeenCalledWith({
key: 'app:canonicalUidAlias:app-old-uid-123',
});
expect(mockDb.read).toHaveBeenNthCalledWith(
2,
expect.stringContaining('WHERE apps.uid = ?'),
['app-canonical-uid-123'],
);
});
it('should throw an error when neither uid nor id is provided', async () => {
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.read.call(appService, {})).rejects.toThrow(
'read requires either uid or id',
);
});
it('should throw an error for invalid complex id keys', async () => {
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.read.call(appService, { id: { invalidKey: 'value' } })).rejects.toThrow('Invalid complex id keys');
});
it('should correctly coerce boolean fields from database', async () => {
const mockRow = createMockAppRow({
approved_for_incentive_program: 1,
approved_for_listing: '1',
approved_for_opening_items: 0,
background: '0',
godmode: 1,
is_private: '1',
maximize_on_start: '1',
protected: 0,
});
mockDb.read.mockResolvedValue([mockRow]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, { uid: 'app-uid-123' });
expect(result.approved_for_incentive_program).toBe(true);
expect(result.approved_for_listing).toBe(true);
expect(result.approved_for_opening_items).toBe(false);
expect(result.background).toBe(false);
expect(result.godmode).toBe(true);
expect(result.is_private).toBe(true);
expect(result.maximize_on_start).toBe(true);
expect(result.protected).toBe(false);
});
it('should parse filetypes JSON and strip leading dots', async () => {
const mockRow = createMockAppRow({
filetypes: '[".txt", ".doc", "pdf"]',
});
mockDb.read.mockResolvedValueOnce([mockRow]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, { uid: 'app-uid-123' });
expect(result.filetype_associations).toEqual(['txt', 'doc', 'pdf']);
expect(mockDb.read).toHaveBeenCalledTimes(1);
});
it('should filter out null values in filetypes array', async () => {
const mockRow = createMockAppRow({
filetypes: '[".txt", null, "pdf"]',
});
mockDb.read.mockResolvedValueOnce([mockRow]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, { uid: 'app-uid-123' });
expect(result.filetype_associations).toEqual(['txt', 'pdf']);
expect(mockDb.read).toHaveBeenCalledTimes(1);
});
it('should query filetype associations table when filetypes JSON is missing', async () => {
const mockRow = createMockAppRow({ filetypes: null });
mockDb.read
.mockResolvedValueOnce([mockRow])
.mockResolvedValueOnce([
{ type: '.txt' },
{ type: null },
{ type: 'pdf' },
]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, { uid: 'app-uid-123' });
expect(result.filetype_associations).toEqual(['txt', 'pdf']);
expect(mockDb.read).toHaveBeenCalledTimes(2);
expect(mockDb.read).toHaveBeenNthCalledWith(
2,
'SELECT type FROM app_filetype_association WHERE app_id = ?',
[mockRow.id],
);
});
it('should have owner parameter', async () => {
const mockRow = createMockAppRow();
mockDb.read.mockResolvedValue([mockRow]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, { uid: 'app-uid-123' });
expect(result.owner).toEqual({
username: 'testuser',
uuid: 'user-uuid-456',
});
});
it('should include app_owner in the result', async () => {
const mockRow = createMockAppRow();
mockDb.read.mockResolvedValue([mockRow]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, { uid: 'app-uid-123' });
expect(result.app_owner).toEqual({
uid: 'owner-app-uid-789',
});
});
it('should fetch icon with size when icon_size param is provided', async () => {
const mockRow = createMockAppRow();
mockDb.read.mockResolvedValue([mockRow]);
const mockIconService = {
getAppIconPath: vi.fn().mockReturnValue('/app-icon/app-uid-123/64'),
};
appService.context = {
get: vi.fn().mockImplementation((key) => {
if ( key === 'services' ) {
return {
get: vi.fn().mockImplementation((name) => {
if ( name === 'app-icon' ) return mockIconService;
return null;
}),
};
}
return null;
}),
};
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, {
uid: 'app-uid-123',
params: { icon_size: 64 },
});
expect(mockIconService.getAppIconPath).toHaveBeenCalledWith({
appUid: 'app-uid-123',
size: 64,
});
expect(result.icon).toBe('/app-icon/app-uid-123/64');
});
it('should route base64 icons through app-icon endpoint even without icon_size', async () => {
const mockRow = createMockAppRow({
icon: 'data:image/png;base64,abc123',
icon_is_base64: 1,
});
mockDb.read.mockResolvedValue([mockRow]);
const mockIconService = {
getAppIconPath: vi.fn().mockReturnValue('/app-icon/app-uid-123/128'),
};
appService.context = {
get: vi.fn().mockImplementation((key) => {
if ( key === 'services' ) {
return {
get: vi.fn().mockImplementation((name) => {
if ( name === 'app-icon' ) return mockIconService;
return null;
}),
};
}
return null;
}),
};
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, { uid: 'app-uid-123' });
expect(mockIconService.getAppIconPath).toHaveBeenCalledWith({
appUid: 'app-uid-123',
size: undefined,
});
expect(result.icon).toBe('/app-icon/app-uid-123/128');
});
it('should keep original icon when icon service throws', async () => {
const mockRow = createMockAppRow();
mockDb.read.mockResolvedValue([mockRow]);
const mockErrorService = {
report: vi.fn(),
};
const mockIconService = {
getAppIconPath: vi.fn().mockImplementation(() => {
throw new Error('Icon fetch failed');
}),
};
appService.context = {
get: vi.fn().mockImplementation((key) => {
if ( key === 'services' ) {
return {
get: vi.fn().mockImplementation((name) => {
if ( name === 'app-icon' ) return mockIconService;
if ( name === 'error-service' ) return mockErrorService;
return null;
}),
};
}
return null;
}),
};
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.read.call(appService, {
uid: 'app-uid-123',
params: { icon_size: 64 },
});
expect(mockErrorService.report).toHaveBeenCalledWith(
'AppES:read_transform',
expect.objectContaining({ source: expect.any(Error) }),
);
expect(result.icon).toBe('icon.png');
});
});
describe('#select', () => {
it('should select all apps with default parameters', async () => {
const mockRows = [
createMockAppRow({ id: 1, uid: 'app-1', name: 'app-one' }),
createMockAppRow({ id: 2, uid: 'app-2', name: 'app-two' }),
];
mockDb.read.mockResolvedValue(mockRows);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.select.call(appService, {});
expect(mockDb.read).toHaveBeenCalledTimes(1);
expect(mockDb.read).toHaveBeenCalledWith(
expect.not.stringContaining('WHERE'),
[],
);
expect(result).toHaveLength(2);
expect(result[0].uid).toBe('app-1');
expect(result[1].uid).toBe('app-2');
});
it('should filter by user-can-edit predicate', async () => {
const mockUser = { id: 42 };
Context.get.mockReturnValue(mockUser);
const mockRows = [createMockAppRow()];
mockDb.read.mockResolvedValue(mockRows);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.select.call(appService, {
predicate: ['user-can-edit'],
});
expect(mockDb.read).toHaveBeenCalledWith(
expect.stringContaining('WHERE apps.owner_user_id=?'),
[42],
);
expect(result).toHaveLength(1);
});
it('should throw error when predicate is not an array', async () => {
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.select.call(appService, { predicate: 'invalid' })).rejects.toThrow('predicate must be an array');
});
it('should correctly coerce boolean fields for all selected apps', async () => {
const mockRows = [
createMockAppRow({
id: 1,
approved_for_listing: 1,
godmode: 0,
}),
createMockAppRow({
id: 2,
approved_for_listing: '0',
godmode: '1',
}),
];
mockDb.read.mockResolvedValue(mockRows);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.select.call(appService, {});
expect(result[0].approved_for_listing).toBe(true);
expect(result[0].godmode).toBe(false);
expect(result[1].approved_for_listing).toBe(false);
expect(result[1].godmode).toBe(true);
});
it('should parse filetypes for all selected apps', async () => {
const mockRows = [
createMockAppRow({ id: 1, filetypes: '[".txt"]' }),
createMockAppRow({ id: 2, filetypes: '[".pdf", ".doc"]' }),
];
mockDb.read.mockResolvedValue(mockRows);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.select.call(appService, {});
expect(result[0].filetype_associations).toEqual(['txt']);
expect(result[1].filetype_associations).toEqual(['pdf', 'doc']);
});
it('should fetch icons with size for all apps when icon_size is provided', async () => {
const mockRows = [
createMockAppRow({ id: 1, uid: 'app-1', icon: 'icon1.png' }),
createMockAppRow({ id: 2, uid: 'app-2', icon: 'icon2.png' }),
];
mockDb.read.mockResolvedValue(mockRows);
const mockIconService = {
getAppIconPath: vi.fn().mockImplementation(({ appUid, size }) => `/app-icon/${appUid}/${size}`),
};
appService.context = {
get: vi.fn().mockImplementation((key) => {
if ( key === 'services' ) {
return {
get: vi.fn().mockImplementation((name) => {
if ( name === 'app-icon' ) return mockIconService;
return null;
}),
};
}
return null;
}),
};
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.select.call(appService, {
params: { icon_size: 32 },
});
expect(mockIconService.getAppIconPath).toHaveBeenCalledTimes(2);
expect(result[0].icon).toBe('/app-icon/app-1/32');
expect(result[1].icon).toBe('/app-icon/app-2/32');
});
it('should only route base64 icons through app-icon endpoint when icon_size is not provided', async () => {
const mockRows = [
createMockAppRow({
id: 1,
uid: 'app-1',
icon: 'data:image/png;base64,abc123',
icon_is_base64: 1,
}),
createMockAppRow({
id: 2,
uid: 'app-2',
icon: 'https://puter-app-icons.puter.site/app-2-128.png',
icon_is_base64: 0,
}),
];
mockDb.read.mockResolvedValue(mockRows);
const mockIconService = {
getAppIconPath: vi.fn().mockImplementation(({ appUid }) => `/app-icon/${appUid}/128`),
};
appService.context = {
get: vi.fn().mockImplementation((key) => {
if ( key === 'services' ) {
return {
get: vi.fn().mockImplementation((name) => {
if ( name === 'app-icon' ) return mockIconService;
return null;
}),
};
}
return null;
}),
};
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.select.call(appService, {});
expect(mockIconService.getAppIconPath).toHaveBeenCalledTimes(1);
expect(result[0].icon).toBe('/app-icon/app-1/128');
expect(result[1].icon).toBe('https://puter-app-icons.puter.site/app-2-128.png');
});
it('should return empty array when no apps exist', async () => {
mockDb.read.mockResolvedValue([]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.select.call(appService, {});
expect(result).toEqual([]);
});
it('should have owner parameter for all selected apps', async () => {
const mockRows = [
createMockAppRow({
id: 1,
owner_user_username: 'user1',
owner_user_uuid: 'uuid-1',
}),
createMockAppRow({
id: 2,
owner_user_username: 'user2',
owner_user_uuid: 'uuid-2',
}),
];
mockDb.read.mockResolvedValue(mockRows);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.select.call(appService, {});
expect(result[0].owner).toEqual({
username: 'user1',
uuid: 'uuid-1',
});
expect(result[1].owner).toEqual({
username: 'user2',
uuid: 'uuid-2',
});
});
it('should handle filetypes that are not strings', async () => {
const mockRows = [
createMockAppRow({ id: 1, filetypes: '[".txt", 123]' }),
];
mockDb.read.mockResolvedValue(mockRows);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.select.call(appService, {})).rejects.toThrow(
'expected filetypesAsJSON[1] to be a string',
);
});
it('should handle malformed filetypes JSON', async () => {
const mockRows = [
createMockAppRow({ id: 1, filetypes: 'not valid json' }),
];
mockDb.read.mockResolvedValue(mockRows);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.select.call(appService, {})).rejects.toThrow(
'failed to get app filetype associations',
);
});
it('should not require dialect-specific JSON aggregation for app selection', async () => {
mockDb.read.mockResolvedValue([]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.select.call(appService, {});
expect(mockDb.case).not.toHaveBeenCalled();
});
});
describe('#build_complex_id_where (via #read)', () => {
it('should accept "name" as a valid redundant identifier', async () => {
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.read.call(appService, { id: { name: 'test' } });
expect(mockDb.read).toHaveBeenCalledWith(
expect.stringContaining('apps.name = ?'),
['test'],
);
});
it('should reject identifiers not in REDUNDANT_IDENTIFIERS', async () => {
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.read.call(appService, { id: { title: 'test' } })).rejects.toThrow('Invalid complex id keys: title');
});
});
describe('#create', () => {
it('should create an app with valid input', async () => {
setupContextForWrite(createMockUserActor(1));
// Mock the read after insert
mockDb.read.mockImplementation(async (query) => {
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
return [];
}
return [createMockAppRow({
uid: expect.stringContaining('app-'),
name: 'new-app',
title: 'New App',
index_url: 'https://example.com/new',
})];
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'new-app',
title: 'New App',
index_url: 'https://example.com/new',
},
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO apps'),
expect.arrayContaining(['new-app', 'New App', 'https://example.com/new']),
);
});
it('should throw forbidden for non-user actors', async () => {
// Mock an invalid actor type
Context.get.mockImplementation((key) => {
if ( key === 'actor' ) return { type: {} };
return null;
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
},
})).rejects.toThrow();
});
it('should throw field_missing when name is not provided', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.create.call(appService, {
object: {
title: 'Test',
index_url: 'https://example.com',
},
})).rejects.toThrow();
});
it('should throw field_missing when title is not provided', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.create.call(appService, {
object: {
name: 'test-app',
index_url: 'https://example.com',
},
})).rejects.toThrow();
});
it('should throw field_missing when index_url is not provided', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
},
})).rejects.toThrow();
});
it('should remove protected fields from input', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
last_review: '2024-01-01', // protected field
},
});
// The INSERT should not include last_review
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO apps'),
expect.not.arrayContaining(['2024-01-01']),
);
});
it('should remove read_only fields from input', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
approved_for_listing: true, // read_only field
godmode: true, // read_only field
is_private: true, // read_only field
},
});
// These fields should not appear in the INSERT
const writeCall = mockDbWrite.write.mock.calls[0];
expect(writeCall[0]).not.toContain('approved_for_listing');
expect(writeCall[0]).not.toContain('godmode');
expect(writeCall[0]).not.toContain('is_private');
});
it('should handle name conflict with dedupe_name option', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
// First check returns true (name exists), second returns false
app_name_exists
.mockResolvedValueOnce(true) // 'new-app' exists
.mockResolvedValueOnce(false); // 'new-app-1' doesn't exist
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'new-app',
title: 'New App',
index_url: 'https://example.com',
},
options: { dedupe_name: true },
});
// Should have inserted with deduped name
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO apps'),
expect.arrayContaining(['new-app-1']),
);
});
it('should throw error when name conflict without dedupe_name', async () => {
setupContextForWrite(createMockUserActor(1));
app_name_exists.mockResolvedValue(true);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.create.call(appService, {
object: {
name: 'existing-app',
title: 'Test',
index_url: 'https://example.com',
},
})).rejects.toThrow();
});
it('should allow equivalent index_url already in use on create for non-hosted origins', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockImplementation(async (query) => {
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
return [{
id: 999,
uid: 'app-existing-uid',
owner_user_id: 1,
index_url: 'https://example.com/',
}];
}
return [createMockAppRow()];
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.create.call(appService, {
object: {
name: 'new-app',
title: 'New App',
index_url: 'https://example.com/index.html',
},
})).resolves.toBeDefined();
});
it('should allow duplicate dev-center placeholder index_url on create', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockImplementation(async (query) => {
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
return [{
id: 999,
uid: 'app-existing-placeholder',
owner_user_id: 1,
index_url: 'https://dev-center.puter.com/coming-soon.html',
}];
}
return [createMockAppRow()];
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.create.call(appService, {
object: {
name: 'new-app',
title: 'New App',
index_url: 'https://dev-center.puter.com/coming-soon.html',
},
})).resolves.toBeDefined();
});
it('should join existing hosted app when index_url is owned and already used', async () => {
setupContextForWrite(createMockUserActor(1));
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
mockDb.read.mockImplementation(async (query) => {
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
return [{
id: 999,
uid: 'app-existing-hosted',
owner_user_id: null,
index_url: 'https://mysite.puter.site',
}];
}
return [createMockAppRow({
id: 999,
uid: 'app-existing-hosted',
name: 'existing-hosted-app',
title: 'Existing Hosted App',
index_url: 'https://mysite.puter.site',
owner_user_id: 1,
})];
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
const joined = await crudQ.create.call(appService, {
object: {
name: 'joined-hosted-app',
title: 'Joined Hosted App',
index_url: 'https://mysite.puter.site',
},
});
expect(joined.uid).toBe('app-existing-hosted');
expect(mockDbWrite.write).not.toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO apps'),
expect.any(Array),
);
expect(mockKvStoreService.set).not.toHaveBeenCalled();
});
it('should throw when hosted index_url is already in use by another owner on create', async () => {
setupContextForWrite(createMockUserActor(1));
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
mockDb.read.mockImplementation(async (query) => {
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
return [{
id: 999,
uid: 'app-existing-hosted',
owner_user_id: 2,
index_url: 'https://mysite.puter.site',
}];
}
return [createMockAppRow()];
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.create.call(appService, {
object: {
name: 'new-app',
title: 'New App',
index_url: 'https://mysite.puter.site',
},
})).rejects.toMatchObject({
fields: {
code: 'app_index_url_already_in_use',
},
});
});
it('should set app_owner when actor is AppUnderUserActorType', async () => {
setupContextForWrite(createMockAppUnderUserActor(1, 100));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
},
});
// Should include app_owner in the INSERT
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('app_owner'),
expect.arrayContaining([100]),
);
});
it('should emit app.new-icon event when icon is provided', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
icon: 'data:image/png;base64,abc123',
},
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.new-icon',
expect.objectContaining({
data_url: 'data:image/png;base64,abc123',
}),
);
});
it('should accept raw base64 icon and normalize to data URL on create', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const rawBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
icon: rawBase64,
},
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.new-icon',
expect.objectContaining({
data_url: `data:image/png;base64,${rawBase64}`,
}),
);
});
it('should migrate relative app-icon endpoint path to absolute URL on create', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
validate_url.mockImplementation((_value, { key }) => {
if ( key === 'icon' ) {
throw new Error('icon should not be validated as a URL');
}
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
icon: '/app-icon/app-uid-123/64',
},
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.new-icon',
expect.objectContaining({
data_url: 'https://api.puter.localhost/app-icon/app-uid-123',
}),
);
expect(validate_url).toHaveBeenCalledWith('https://example.com', expect.objectContaining({ key: 'index_url' }));
});
it('should reject object icon payloads on create', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
icon: { url: '/app-icon/app-uid-123/64' },
},
})).rejects.toMatchObject({
fields: { code: 'field_invalid', key: 'icon' },
});
});
it('should allow empty icon string on create', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
icon: '',
},
});
expect(mockEventService.emit).not.toHaveBeenCalledWith('app.new-icon', expect.anything());
});
it('should migrate legacy app-icons host URL to app-icon endpoint URL on create', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
icon: 'https://puter-app-icons.puter.site/app-uid-123-64.png',
},
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.new-icon',
expect.objectContaining({
data_url: 'https://api.puter.localhost/app-icon/app-uid-123',
}),
);
});
it('should allow absolute app-icon endpoint URL on API origin', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
icon: 'https://api.puter.localhost/app-icon/app-uid-123/64',
},
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.new-icon',
expect.objectContaining({
data_url: 'https://api.puter.localhost/app-icon/app-uid-123',
}),
);
});
it('should reject foreign absolute app-icon endpoint URL on create', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
icon: 'https://evil.example/app-icon/app-uid-123/64',
},
})).rejects.toMatchObject({
fields: { code: 'field_invalid', key: 'icon' },
});
});
it('should reject non app-icon URL icon on create', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
icon: 'https://example.com/webhook',
},
})).rejects.toMatchObject({
fields: { code: 'field_invalid', key: 'icon' },
});
});
it('should handle filetype_associations', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
filetype_associations: ['txt', 'pdf'],
},
});
// Should have three write calls: INSERT app, DELETE old associations, INSERT new associations
// (DELETE is called even for create since #update_filetype_associations always clears first)
expect(mockDbWrite.write).toHaveBeenCalledTimes(3);
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM app_filetype_association'),
[1],
);
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO app_filetype_association'),
expect.arrayContaining([1, 'txt', 1, 'pdf']),
);
});
it('should call validate_string for name and title', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test Title',
index_url: 'https://example.com',
},
});
expect(validate_string).toHaveBeenCalledWith('test-app', expect.objectContaining({ key: 'name' }));
expect(validate_string).toHaveBeenCalledWith('Test Title', expect.objectContaining({ key: 'title' }));
});
it('should call validate_url for index_url', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockImplementation(async (query) => {
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
return [];
}
return [createMockAppRow()];
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com/app',
},
});
expect(validate_url).toHaveBeenCalledWith('https://example.com/app', expect.objectContaining({ key: 'index_url' }));
});
it('should generate a UID with app- prefix', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.create.call(appService, {
object: {
name: 'test-app',
title: 'Test',
index_url: 'https://example.com',
},
});
const writeCall = mockDbWrite.write.mock.calls[0];
const values = writeCall[1];
const uidValue = values[0]; // uid is first value
expect(uidValue).toMatch(/^app-[0-9a-f-]{36}$/);
});
});
describe('#update', () => {
beforeEach(() => {
// Default: return an existing app for updates
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
})]);
});
it('should update an app with valid input', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: { uid: 'app-uid-123', title: 'Updated Title' },
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.arrayContaining(['Updated Title', 'app-uid-123']),
);
});
it('should throw entity_not_found when app does not exist', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: { uid: 'nonexistent-uid', title: 'Test' },
})).rejects.toThrow();
});
it('should throw forbidden when user does not own the app', async () => {
// User 2 trying to update app owned by user 1
setupContextForWrite(createMockUserActor(2), { id: 2 });
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
})]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: { uid: 'app-uid-123', title: 'Hacked Title' },
})).rejects.toThrow();
});
it('should allow update when user has write-all-owners permission', async () => {
setupContextForWrite(createMockUserActor(2), { id: 2 });
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
})]);
mockPermissionService.check.mockResolvedValue(true);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: { uid: 'app-uid-123', title: 'Admin Update' },
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.arrayContaining(['Admin Update']),
);
});
it('should remove protected fields from update', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
title: 'Updated',
last_review: '2024-12-01', // protected field
},
});
const writeCall = mockDbWrite.write.mock.calls[0];
expect(writeCall[0]).not.toContain('last_review');
});
it('should remove read_only fields from update', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
title: 'Updated',
approved_for_listing: true,
godmode: true,
is_private: true,
},
});
const writeCall = mockDbWrite.write.mock.calls[0];
expect(writeCall[0]).not.toContain('approved_for_listing');
expect(writeCall[0]).not.toContain('godmode');
expect(writeCall[0]).not.toContain('is_private');
});
it('should handle name change with conflict', async () => {
setupContextForWrite(createMockUserActor(1));
app_name_exists.mockResolvedValue(true);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: { uid: 'app-uid-123', name: 'taken-name' },
})).rejects.toThrow();
});
it('should allow name change with dedupe_name option', async () => {
setupContextForWrite(createMockUserActor(1));
app_name_exists
.mockResolvedValueOnce(true) // 'new-name' exists
.mockResolvedValueOnce(false); // 'new-name-1' doesn't exist
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: { uid: 'app-uid-123', name: 'new-name' },
options: { dedupe_name: true },
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.arrayContaining(['new-name-1']),
);
});
it('should allow reclaiming old app name', async () => {
setupContextForWrite(createMockUserActor(1));
app_name_exists.mockResolvedValue(true);
mockOldAppNameService.check_app_name.mockResolvedValue({
id: 99,
app_uid: 'app-uid-123', // Same app
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: { uid: 'app-uid-123', name: 'old-name' },
});
expect(mockOldAppNameService.remove_name).toHaveBeenCalledWith(99);
expect(mockDbWrite.write).toHaveBeenCalled();
});
it('should not update name if unchanged', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: { uid: 'app-uid-123', name: 'test-app' }, // Same as existing
});
// Should only have the read for ID, no name in update
const writeCall = mockDbWrite.write.mock.calls.find(call => call[0].includes('UPDATE'));
if ( writeCall ) {
expect(writeCall[1]).not.toContain('test-app');
}
});
it('should emit app.new-icon event when icon changes', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
icon: 'data:image/png;base64,newicon',
},
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.new-icon',
expect.objectContaining({
app_uid: 'app-uid-123',
data_url: 'data:image/png;base64,newicon',
}),
);
});
it('should accept raw base64 icon and normalize to data URL on update', async () => {
setupContextForWrite(createMockUserActor(1));
const rawBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
icon: rawBase64,
},
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.new-icon',
expect.objectContaining({
app_uid: 'app-uid-123',
data_url: `data:image/png;base64,${rawBase64}`,
}),
);
});
it('should migrate relative app-icon endpoint path to absolute URL on update', async () => {
setupContextForWrite(createMockUserActor(1));
validate_url.mockImplementation((_value, { key }) => {
if ( key === 'icon' ) {
throw new Error('icon should not be validated as a URL');
}
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
icon: '/app-icon/app-uid-123/64',
},
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.new-icon',
expect.objectContaining({
app_uid: 'app-uid-123',
data_url: 'https://api.puter.localhost/app-icon/app-uid-123',
}),
);
});
it('should reject object icon payloads on update', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
icon: { url: '/app-icon/app-uid-123/64' },
},
})).rejects.toMatchObject({
fields: { code: 'field_invalid', key: 'icon' },
});
});
it('should allow empty icon string on update', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
icon: '',
},
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.new-icon',
expect.objectContaining({
app_uid: 'app-uid-123',
data_url: '',
}),
);
});
it('should migrate legacy app-icons host URL to app-icon endpoint URL on update', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
icon: 'https://puter-app-icons.puter.site/app-uid-123-64.png',
},
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.new-icon',
expect.objectContaining({
app_uid: 'app-uid-123',
data_url: 'https://api.puter.localhost/app-icon/app-uid-123',
}),
);
});
it('should allow absolute app-icon endpoint URL on API origin when updating icon', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
icon: 'https://api.puter.localhost/app-icon/app-uid-123/64',
},
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.new-icon',
expect.objectContaining({
app_uid: 'app-uid-123',
data_url: 'https://api.puter.localhost/app-icon/app-uid-123',
}),
);
});
it('should reject foreign absolute app-icon endpoint URL on update', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
icon: 'https://evil.example/app-icon/app-uid-123/64',
},
})).rejects.toMatchObject({
fields: { code: 'field_invalid', key: 'icon' },
});
});
it('should reject non app-icon URL icon on update', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
icon: 'https://example.com/webhook',
},
})).rejects.toMatchObject({
fields: { code: 'field_invalid', key: 'icon' },
});
});
it('should emit app.rename event when name changes', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: { uid: 'app-uid-123', name: 'renamed-app' },
});
expect(mockEventService.emit).toHaveBeenCalledWith(
'app.rename',
expect.objectContaining({
app_uid: 'app-uid-123',
new_name: 'renamed-app',
old_name: 'test-app',
}),
);
});
it('should update filetype_associations', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
filetype_associations: ['doc', 'xls'],
},
});
// Should delete old associations
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM app_filetype_association'),
[1],
);
// Should insert new associations
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO app_filetype_association'),
expect.arrayContaining([1, 'doc', 1, 'xls']),
);
});
it('should validate fields when provided', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
name: 'updated-name',
title: 'Updated Title',
description: 'Updated description',
index_url: 'https://updated.com',
},
});
expect(validate_string).toHaveBeenCalledWith('updated-name', expect.objectContaining({ key: 'name' }));
expect(validate_string).toHaveBeenCalledWith('Updated Title', expect.objectContaining({ key: 'title' }));
expect(validate_string).toHaveBeenCalledWith('Updated description', expect.objectContaining({ key: 'description' }));
expect(validate_url).toHaveBeenCalledWith('https://updated.com', expect.objectContaining({ key: 'index_url' }));
});
it('should check subdomain ownership when index_url changes to puter.site', async () => {
setupContextForWrite(createMockUserActor(1));
mockPuterSiteService.get_subdomain.mockResolvedValue(null);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
index_url: 'https://mysite.puter.site',
},
})).rejects.toThrow();
});
it('should allow index_url change when subdomain is owned', async () => {
setupContextForWrite(createMockUserActor(1));
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
index_url: 'https://mysite.puter.site',
},
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.arrayContaining(['https://mysite.puter.site']),
);
});
it('should allow index_url change when private hosted subdomain is owned', async () => {
setupContextForWrite(createMockUserActor(1));
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
index_url: 'https://mysite.puter.dev',
},
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.arrayContaining(['https://mysite.puter.dev']),
);
});
it('should allow equivalent index_url already in use on update for non-hosted origins', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockImplementation(async (query) => {
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
return [{
id: 777,
uid: 'app-conflict-uid',
owner_user_id: 2,
index_url: 'https://updated.com/',
}];
}
return [createMockAppRow()];
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
index_url: 'https://updated.com/index.html',
},
})).resolves.toBeDefined();
});
it('should allow duplicate dev-center placeholder index_url on update', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockImplementation(async (query) => {
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
return [{
id: 777,
uid: 'app-existing-placeholder',
owner_user_id: 1,
index_url: 'https://dev-center.puter.com/coming-soon.html',
}];
}
return [createMockAppRow()];
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
index_url: 'https://dev-center.puter.com/coming-soon.html',
},
})).resolves.toBeDefined();
});
it('should join existing unowned hosted app when index_url is already in use on update', async () => {
setupContextForWrite(createMockUserActor(1));
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
let readCallCount = 0;
mockDb.read.mockImplementation(async (query, params) => {
readCallCount++;
if ( readCallCount > 100 ) {
throw new Error(`excessive mockDb.read calls in join test: ${String(query)} :: ${JSON.stringify(params)}`);
}
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
if ( Array.isArray(params) && params[params.length - 1] === 777 ) {
// Mirrors SQL `AND id != ?` behavior during join follow-up updates.
return [];
}
return [{
id: 777,
uid: 'app-conflict-uid',
owner_user_id: null,
index_url: 'https://mysite.puter.site/',
}];
}
if ( Array.isArray(params) && params[0] === 'app-conflict-uid' ) {
return [createMockAppRow({
id: 777,
uid: 'app-conflict-uid',
name: 'existing-hosted-app',
title: 'Existing Hosted App',
index_url: 'https://mysite.puter.site/',
owner_user_id: 1,
})];
}
return [createMockAppRow({
id: 1,
uid: 'app-uid-123',
name: 'updating-app',
title: 'Updating App',
index_url: 'https://other.puter.site',
owner_user_id: 1,
})];
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
title: 'Joined Update Title',
index_url: 'https://mysite.puter.site/index.html',
},
});
expect(result.uid).toBe('app-conflict-uid');
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.arrayContaining(['Joined Update Title', 'app-conflict-uid']),
);
expect(mockAppInformationService.delete_app).toHaveBeenCalledWith(
'app-uid-123',
undefined,
{ preserveCanonicalUidAlias: true },
);
expect(mockKvStoreService.set).toHaveBeenCalledWith(expect.objectContaining({
key: 'app:canonicalUidAlias:app-uid-123',
value: 'app-conflict-uid',
}));
});
it('should throw when owned hosted index_url is already in use on update', async () => {
setupContextForWrite(createMockUserActor(1));
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
mockDb.read.mockImplementation(async (query) => {
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
return [{
id: 777,
uid: 'app-conflict-uid',
owner_user_id: 1,
index_url: 'https://mysite.puter.site/',
}];
}
return [createMockAppRow()];
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
index_url: 'https://mysite.puter.site/index.html',
},
})).rejects.toMatchObject({
fields: {
code: 'app_index_url_already_in_use',
},
});
});
it('should throw when equivalent hosted index_url is already in use on update', async () => {
setupContextForWrite(createMockUserActor(1));
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
mockDb.read.mockImplementation(async (query) => {
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
return [{
id: 777,
uid: 'app-conflict-uid',
owner_user_id: 2,
index_url: 'https://mysite.puter.site/',
}];
}
return [createMockAppRow()];
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: {
uid: 'app-uid-123',
index_url: 'https://mysite.puter.site/index.html',
},
})).rejects.toMatchObject({
fields: {
code: 'app_index_url_already_in_use',
},
});
});
it('should throw forbidden when app actor does not own the entity (AppLimitedES behavior)', async () => {
// App actor trying to update an app it didn't create
setupContextForWrite(createMockAppUnderUserActor(1, 999));
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
app_owner_uid: 'different-app-uid',
})]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: { uid: 'app-uid-123', title: 'Hacked Title' },
})).rejects.toThrow();
});
it('should allow app actor to update entity it owns (AppLimitedES behavior)', async () => {
// App actor updating an app it created
const actor = createMockAppUnderUserActor(1, 100);
actor.type.app.uid = 'creator-app-uid';
setupContextForWrite(actor);
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
app_owner_uid: 'creator-app-uid',
})]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: { uid: 'app-uid-123', title: 'Updated by App' },
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.arrayContaining(['Updated by App']),
);
});
it('should allow app actor with write permission to update any entity (AppLimitedES behavior)', async () => {
setupContextForWrite(createMockAppUnderUserActor(1, 999));
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
app_owner_uid: 'different-app-uid',
})]);
// Grant write permission
mockPermissionService.check.mockResolvedValue(true);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: { uid: 'app-uid-123', title: 'Admin Update' },
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.arrayContaining(['Admin Update']),
);
});
});
describe('#upsert', () => {
it('should call create when entity does not exist', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.upsert.call(appService, {
object: {
name: 'new-app',
title: 'New App',
index_url: 'https://example.com',
},
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO apps'),
expect.any(Array),
);
});
it('should call update when entity exists', async () => {
setupContextForWrite(createMockUserActor(1));
// Read returns existing entity
mockDb.read.mockResolvedValue([createMockAppRow()]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.upsert.call(appService, {
object: { uid: 'app-uid-123', title: 'Updated Title' },
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.any(Array),
);
});
});
describe('#delete', () => {
beforeEach(() => {
// Mock app-information service
mockAppInformationService = {
delete_app: vi.fn().mockResolvedValue(undefined),
};
// Update mockServices to include app-information
mockServices.get.mockImplementation((serviceName) => {
if ( serviceName === 'database' ) {
return {
get: vi.fn().mockImplementation((mode) => {
if ( mode === 'write' ) return mockDbWrite;
return mockDb;
}),
};
}
if ( serviceName === 'event' ) return mockEventService;
if ( serviceName === 'permission' ) return mockPermissionService;
if ( serviceName === 'puter-site' ) return mockPuterSiteService;
if ( serviceName === 'old-app-name' ) return mockOldAppNameService;
if ( serviceName === 'app-information' ) return mockAppInformationService;
if ( serviceName === 'puter-kvstore' ) return mockKvStoreService;
if ( serviceName === 'su' ) return mockSuService;
return null;
});
// Default: return an existing app for deletes
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
})]);
});
it('should delete an app by uid', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.delete.call(appService, { uid: 'app-uid-123' });
expect(mockAppInformationService.delete_app).toHaveBeenCalledWith('app-uid-123');
expect(result.success).toBe(true);
expect(result.uid).toBe('app-uid-123');
});
it('should delete an app by complex id (name)', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.delete.call(appService, { id: { name: 'test-app' } });
expect(mockAppInformationService.delete_app).toHaveBeenCalledWith('app-uid-123');
expect(result.success).toBe(true);
});
it('should throw entity_not_found when app does not exist', async () => {
setupContextForWrite(createMockUserActor(1));
mockDb.read.mockResolvedValue([]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.delete.call(appService, { uid: 'nonexistent-uid' }))
.rejects.toThrow();
});
it('should throw forbidden for non-user actors', async () => {
Context.get.mockImplementation((key) => {
if ( key === 'actor' ) return { type: {} };
return null;
});
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.delete.call(appService, { uid: 'app-uid-123' }))
.rejects.toThrow();
});
it('should throw forbidden when user does not own the app', async () => {
// User 2 trying to delete app owned by user 1
setupContextForWrite(createMockUserActor(2), { id: 2 });
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
})]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.delete.call(appService, { uid: 'app-uid-123' }))
.rejects.toThrow();
});
it('should allow delete when user has write-all-owners permission', async () => {
setupContextForWrite(createMockUserActor(2), { id: 2 });
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
})]);
mockPermissionService.check.mockResolvedValue(true);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.delete.call(appService, { uid: 'app-uid-123' });
expect(mockAppInformationService.delete_app).toHaveBeenCalled();
expect(result.success).toBe(true);
});
it('should invalidate app cache after delete', async () => {
setupContextForWrite(createMockUserActor(1));
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.delete.call(appService, { uid: 'app-uid-123' });
});
it('should throw forbidden when app actor does not own the entity', async () => {
// App actor trying to delete an app it didn't create
setupContextForWrite(createMockAppUnderUserActor(1, 999));
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
app_owner_uid: 'different-app-uid',
})]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.delete.call(appService, { uid: 'app-uid-123' }))
.rejects.toThrow();
});
it('should allow app actor to delete entity it owns', async () => {
// App actor deleting an app it created
const actor = createMockAppUnderUserActor(1, 100);
actor.type.app.uid = 'creator-app-uid';
setupContextForWrite(actor);
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
app_owner_uid: 'creator-app-uid',
})]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.delete.call(appService, { uid: 'app-uid-123' });
expect(mockAppInformationService.delete_app).toHaveBeenCalled();
expect(result.success).toBe(true);
});
it('should allow app actor with write permission to delete any entity', async () => {
setupContextForWrite(createMockAppUnderUserActor(1, 999));
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
app_owner_uid: 'different-app-uid',
})]);
// Grant write permission
mockPermissionService.check.mockResolvedValue(true);
const crudQ = AppService.IMPLEMENTS['crud-q'];
const result = await crudQ.delete.call(appService, { uid: 'app-uid-123' });
expect(mockAppInformationService.delete_app).toHaveBeenCalled();
expect(result.success).toBe(true);
});
});
});
================================================
FILE: src/backend/src/modules/data-access/DEV.md
================================================
## Development for `data-access` module
This document will contain notes, documentation, and snippets written
while developing the `data-access` module replacements for what was
formerly handled by EntityStoreService and OM (Object Mapping).
### App List Test Code
This code is used to test listing apps with one of the available
CRUD-implementing drivers.
```javascript
await (async () => {
const resp = await fetch('http://api.puter.localhost:4100/drivers/call', {
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
args: { predicate: ['user-can-edit'] },
driver: 'es:app',
interface: 'puter-apps',
method: 'select',
}),
})
return (await resp.json()).result;
})();
```
### AI-Generated Compare Function
I asked an LLM to find me a javascript object compare function
that I can paste in developer tools and it started generating
one from scratch. To my surprise it worked just fine, so I'm pasting
this here for the time being for convenience:
```javascript
(() => {
// Deep compare + diff reporter for DevTools (no deps)
// Usage:
// const r = deepCompare(a, b);
// console.log(r.pass, r.message);
// r.print(); // pretty console output
// Options:
// deepCompare(a,b,{ showSame:false, maxDiffs:200, sortKeys:true })
function deepCompare(a, b, opts = {}) {
const options = {
showSame: false, // include "same" entries in the diff list
maxDiffs: 200, // cap diffs so you don't nuke your console
sortKeys: true, // stable key ordering when iterating plain objects
...opts,
};
const diffs = [];
const seenPairs = new WeakMap(); // a -> WeakMap(b -> true)
const isObjectLike = (v) => v !== null && (typeof v === "object" || typeof v === "function");
const tagOf = (v) => Object.prototype.toString.call(v); // "[object X]"
const isPlainObject = (v) => {
if (tagOf(v) !== "[object Object]") return false;
const proto = Object.getPrototypeOf(v);
return proto === Object.prototype || proto === null;
};
const typeLabel = (v) => {
if (v === null) return "null";
const t = typeof v;
if (t !== "object") return t;
return tagOf(v).slice(8, -1);
};
const formatVal = (v) => {
// Safe-ish inline formatter for messages (keeps things short)
try {
if (typeof v === "string") return JSON.stringify(v.length > 120 ? v.slice(0, 117) + "…" : v);
if (typeof v === "number" && Object.is(v, -0)) return "-0";
if (typeof v === "bigint") return `${v}n`;
if (typeof v === "symbol") return v.toString();
if (typeof v === "function") return `[Function ${v.name || "anonymous"}]`;
if (v instanceof Date) return isNaN(v.getTime()) ? "Invalid Date" : `Date(${v.toISOString()})`;
if (v instanceof RegExp) return v.toString();
if (v instanceof Map) return `Map(${v.size})`;
if (v instanceof Set) return `Set(${v.size})`;
if (ArrayBuffer.isView(v) && !(v instanceof DataView)) return `${v.constructor.name}(${v.length})`;
if (v instanceof ArrayBuffer) return `ArrayBuffer(${v.byteLength})`;
if (v && v.constructor && v.constructor !== Object) return `${v.constructor.name}{…}`;
if (Array.isArray(v)) return `Array(${v.length})`;
if (isPlainObject(v)) return "Object{…}";
return `${typeLabel(v)}{…}`;
} catch {
return "[Unformattable]";
}
};
const pathToString = (path) => {
if (!path.length) return "(root)";
let s = "";
for (const p of path) {
if (typeof p === "number") s += `[${p}]`;
else if (typeof p === "string") {
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(p)) s += (s ? "." : "") + p;
else s += `[${JSON.stringify(p)}]`;
} else if (typeof p === "symbol") s += `[${p.toString()}]`;
else s += `[${String(p)}]`;
}
return s;
};
const pushDiff = (kind, path, left, right, extra) => {
if (diffs.length >= options.maxDiffs) return;
diffs.push({
kind, // "type" | "value" | "missing-left" | "missing-right" | "prototype" | "keys" | ...
path: [...path],
left,
right,
extra,
});
};
const markSeen = (x, y) => {
if (!isObjectLike(x) || !isObjectLike(y)) return false;
let inner = seenPairs.get(x);
if (!inner) {
inner = new WeakMap();
seenPairs.set(x, inner);
}
if (inner.get(y)) return true;
inner.set(y, true);
return false;
};
const sameValueZero = (x, y) => Object.is(x, y); // handles NaN, -0
const compareArrays = (x, y, path) => {
if (x.length !== y.length) pushDiff("value", [...path, "length"], x.length, y.length, "array length mismatch");
const n = Math.max(x.length, y.length);
for (let i = 0; i < n; i++) {
if (i >= x.length) pushDiff("missing-left", [...path, i], undefined, y[i], "missing index in left");
else if (i >= y.length) pushDiff("missing-right", [...path, i], x[i], undefined, "missing index in right");
else walk(x[i], y[i], [...path, i]);
if (diffs.length >= options.maxDiffs) return;
}
};
const compareTypedArrays = (x, y, path) => {
if (x.constructor !== y.constructor) {
pushDiff("type", path, x.constructor?.name, y.constructor?.name, "typed array class mismatch");
return;
}
if (x.length !== y.length) pushDiff("value", [...path, "length"], x.length, y.length, "typed array length mismatch");
const n = Math.min(x.length, y.length);
for (let i = 0; i < n; i++) {
if (!sameValueZero(x[i], y[i])) pushDiff("value", [...path, i], x[i], y[i], "typed array element mismatch");
if (diffs.length >= options.maxDiffs) return;
}
};
const compareArrayBuffer = (x, y, path) => {
if (x.byteLength !== y.byteLength) {
pushDiff("value", [...path, "byteLength"], x.byteLength, y.byteLength, "ArrayBuffer byteLength mismatch");
return;
}
const a8 = new Uint8Array(x);
const b8 = new Uint8Array(y);
for (let i = 0; i < a8.length; i++) {
if (a8[i] !== b8[i]) {
pushDiff("value", [...path, i], a8[i], b8[i], "ArrayBuffer byte mismatch");
if (diffs.length >= options.maxDiffs) return;
}
}
};
const compareDates = (x, y, path) => {
const tx = x.getTime();
const ty = y.getTime();
if (!sameValueZero(tx, ty)) pushDiff("value", path, x, y, "Date mismatch");
};
const compareRegex = (x, y, path) => {
if (x.source !== y.source || x.flags !== y.flags) pushDiff("value", path, x, y, "RegExp mismatch");
};
const compareMaps = (x, y, path) => {
if (x.size !== y.size) pushDiff("value", [...path, "size"], x.size, y.size, "Map size mismatch");
// Map key equality is identity-based; here we:
// 1) try direct key lookup for primitive keys
// 2) for object keys, we require the *same object reference* exists as key in the other map
// (test frameworks do similar unless they do expensive key deep-matching)
for (const [k, xv] of x.entries()) {
if (!y.has(k)) {
pushDiff("missing-right", [...path, `MapKey(${formatVal(k)})`], xv, undefined, "Map missing key on right");
continue;
}
walk(xv, y.get(k), [...path, `MapKey(${formatVal(k)})`]);
if (diffs.length >= options.maxDiffs) return;
}
for (const [k, yv] of y.entries()) {
if (!x.has(k)) {
pushDiff("missing-left", [...path, `MapKey(${formatVal(k)})`], undefined, yv, "Map missing key on left");
if (diffs.length >= options.maxDiffs) return;
}
}
};
const compareSets = (x, y, path) => {
if (x.size !== y.size) pushDiff("value", [...path, "size"], x.size, y.size, "Set size mismatch");
// Same logic: membership is identity for object values.
for (const v of x.values()) {
if (!y.has(v)) pushDiff("missing-right", [...path, `SetVal(${formatVal(v)})`], v, undefined, "Set missing value on right");
if (diffs.length >= options.maxDiffs) return;
}
for (const v of y.values()) {
if (!x.has(v)) pushDiff("missing-left", [...path, `SetVal(${formatVal(v)})`], undefined, v, "Set missing value on left");
if (diffs.length >= options.maxDiffs) return;
}
};
const comparePlainObjects = (x, y, path) => {
// Compare prototypes (handy when something is class instance vs plain object)
const px = Object.getPrototypeOf(x);
const py = Object.getPrototypeOf(y);
if (px !== py) pushDiff("prototype", path, px?.constructor?.name || px, py?.constructor?.name || py, "Prototype mismatch");
const keysX = Reflect.ownKeys(x);
const keysY = Reflect.ownKeys(y);
const norm = (ks) => {
// Sort only string keys for stability; keep symbols in original order
if (!options.sortKeys) return ks;
const str = ks.filter(k => typeof k === "string").sort();
const sym = ks.filter(k => typeof k === "symbol");
const numLike = []; // keep numeric-looking strings in numeric order if you want; leaving out to stay simple
// We'll just do lexical sort for strings; okay for devtools output.
return [...str, ...sym];
};
const kx = norm(keysX);
const ky = norm(keysY);
const setY = new Set(keysY);
const setX = new Set(keysX);
for (const k of kx) {
if (!setY.has(k)) {
pushDiff("missing-right", [...path, k], x[k], undefined, "Missing property on right");
} else {
walk(x[k], y[k], [...path, k]);
}
if (diffs.length >= options.maxDiffs) return;
}
for (const k of ky) {
if (!setX.has(k)) {
pushDiff("missing-left", [...path, k], undefined, y[k], "Missing property on left");
if (diffs.length >= options.maxDiffs) return;
}
}
};
function walk(x, y, path) {
if (diffs.length >= options.maxDiffs) return;
if (sameValueZero(x, y)) {
if (options.showSame) pushDiff("same", path, x, y);
return;
}
const tx = typeLabel(x);
const ty = typeLabel(y);
if (tx !== ty) {
pushDiff("type", path, tx, ty, "Type mismatch");
return;
}
// Circular / repeated references
if (markSeen(x, y)) return;
// Per-type comparisons
if (Array.isArray(x)) return compareArrays(x, y, path);
if (ArrayBuffer.isView(x) && !(x instanceof DataView)) return compareTypedArrays(x, y, path);
if (x instanceof ArrayBuffer) return compareArrayBuffer(x, y, path);
if (x instanceof Date) return compareDates(x, y, path);
if (x instanceof RegExp) return compareRegex(x, y, path);
if (x instanceof Map) return compareMaps(x, y, path);
if (x instanceof Set) return compareSets(x, y, path);
// Functions: compare by reference already failed; treat as value mismatch
if (typeof x === "function") {
pushDiff("value", path, x, y, "Function reference mismatch");
return;
}
// Objects (including class instances): compare own keys + nested values.
if (isObjectLike(x)) return comparePlainObjects(x, y, path);
// Primitives (should have been caught by Object.is earlier)
pushDiff("value", path, x, y, "Value mismatch");
}
walk(a, b, []);
const pass = diffs.length === 0;
const message = pass
? "✅ Values are deeply equal."
: buildMessage(diffs, options);
function buildMessage(diffs, options) {
const lines = [];
lines.push(`❌ Values differ (${diffs.length}${diffs.length >= options.maxDiffs ? "+" : ""} diff${diffs.length === 1 ? "" : "s"}):`);
for (let i = 0; i < diffs.length; i++) {
const d = diffs[i];
const p = pathToString(d.path);
const left = formatVal(d.left);
const right = formatVal(d.right);
const label = d.kind.padEnd(14, " ");
const extra = d.extra ? ` — ${d.extra}` : "";
lines.push(`${String(i + 1).padStart(3, " ")}. ${label} ${p}${extra}`);
lines.push(` left : ${left}`);
lines.push(` right: ${right}`);
}
if (diffs.length >= options.maxDiffs) {
lines.push(`… (diffs capped at maxDiffs=${options.maxDiffs})`);
}
return lines.join("\n");
}
function print() {
if (pass) {
console.log("%c✅ deepCompare: PASS", "font-weight:bold");
return;
}
console.groupCollapsed(`%c❌ deepCompare: FAIL (${diffs.length}${diffs.length >= options.maxDiffs ? "+" : ""})`, "font-weight:bold");
console.log(message);
// Also log a structured table for quick scanning
const table = diffs.map((d) => ({
kind: d.kind,
path: pathToString(d.path),
left: formatVal(d.left),
right: formatVal(d.right),
note: d.extra || "",
}));
try { console.table(table); } catch {}
console.groupEnd();
}
return { pass, diffs, message, print };
}
// Expose globally for DevTools convenience
window.deepCompare = deepCompare;
console.log("deepCompare installed. Usage: deepCompare(a,b).print()");
})();
```
================================================
FILE: src/backend/src/modules/data-access/DataAccessModule.js
================================================
import { AdvancedBase } from '@heyputer/putility';
import AppService from './AppService.js';
export class DataAccessModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
services.registerService('app', AppService);
}
}
================================================
FILE: src/backend/src/modules/data-access/lib/coercion.js
================================================
// These utility functions describe how values stored in the database
// are to be understood as their higher-level counterparts.
import { CoercionTypeError } from './error.js';
/**
* MySQL lets us store `1` (an integer) or `0` (also an integer) as
* the closest parallel to a boolean "true or false" value.
* Sqlite lets us store `"1"` (a string) or `0` (also a string) as
* the closest parallel to a boolean "true of false" value.
*
* So we define a function here called `as_bool` that will make
* `"0"` or `0` become `false`, and `"1"` or `1` become `true`.
*
* @param {any} value - The value to coerce to a boolean.
* @returns {boolean} The coerced boolean value.
*/
export const as_bool = value => {
if ( value === undefined ) return false;
if ( value === 0 ) value = false;
if ( value === 1 ) value = true;
if ( value === '0' ) value = false;
if ( value === '1' ) value = true;
if ( typeof value !== 'boolean' ) {
throw new CoercionTypeError({ expected: 'boolean', got: typeof value });
}
return value;
};
================================================
FILE: src/backend/src/modules/data-access/lib/error.js
================================================
/**
* Replaces `OMTypeError` from ES/OM implementation.
* This might be removed or replaced in the future.
*/
export class CoercionTypeError extends Error {
constructor ({ expected, got }) {
const message = `expected ${expected}, got ${got}`;
super(message);
this.name = 'CoercionTypeError';
}
}
================================================
FILE: src/backend/src/modules/data-access/lib/filter.js
================================================
// These utility functions describe how to produce an object safe
// for transfer that came from a "raw" object.
export const user_to_client = raw_user => {
return {
username: raw_user.username,
// This `uuid` is not an internal-only ID.
uuid: raw_user.uuid,
};
};
================================================
FILE: src/backend/src/modules/data-access/lib/sqlutil.js
================================================
/**
* When columns are selected from a joined table and prefixed:
*
* SELECT joined_table.* AS joined_table_
*
* This function is able to extract the object from the result:
*
* extract_from_prefix(row, 'joined_table_') // columns of joined_table
*
* @param {*} row
* @param {*} prefix
*/
export const extract_from_prefix = (row, prefix) => {
const result = {};
for ( const [key, value] of Object.entries(row) ) {
if ( key.startsWith(prefix) ) {
result[key.replace(prefix, '')] = value;
}
}
return result;
};
================================================
FILE: src/backend/src/modules/data-access/lib/validation.js
================================================
import validator from 'validator';
import APIError from '../../../api/APIError.js';
/**
* Validates a string value with optional maxlen and regex constraints.
* @param {string} value - The value to validate
* @param {object} meta - Metadata for the validation
* @param {string} meta.key - The field name (for error messages)
* @param {number} [meta.maxlen] - Maximum length allowed
* @param {RegExp} [meta.regex] - Regex pattern the string must match
*/
export const validate_string = (value, { key, maxlen, regex }) => {
if ( typeof value !== 'string' ) {
throw APIError.create('field_invalid', null, { key });
}
if ( maxlen !== undefined && value.length > maxlen ) {
throw APIError.create('field_too_long', null, { key, max_length: maxlen });
}
if ( regex !== undefined && !regex.test(value) ) {
throw APIError.create('field_invalid', null, { key });
}
};
/**
* Validates an image-base64 value (data URL for images).
* Checks for proper prefix and XSS characters.
* @param {string} value - The value to validate
* @param {object} meta - Metadata for the validation
* @param {string} meta.key - The field name (for error messages)
*/
export const validate_image_base64 = (value, { key }) => {
if ( typeof value !== 'string' ) {
throw APIError.create('field_invalid', null, { key });
}
if ( ! value.startsWith('data:image/') ) {
throw APIError.create('field_invalid', null, { key });
}
// XSS character check from image-base64 prop type
const xss_chars = ['<', '>', '&', '"', "'", '`'];
if ( xss_chars.some(char => value.includes(char)) ) {
throw APIError.create('field_invalid', null, { key });
}
};
/**
* Validates a URL value with optional maxlen constraint.
* Uses the validator library, allowing localhost.
* @param {string} value - The value to validate
* @param {object} meta - Metadata for the validation
* @param {string} meta.key - The field name (for error messages)
* @param {number} [meta.maxlen] - Maximum length allowed
*/
export const validate_url = (value, { key, maxlen }) => {
if ( typeof value !== 'string' ) {
throw APIError.create('field_invalid', null, { key });
}
if ( maxlen !== undefined && value.length > maxlen ) {
throw APIError.create('field_too_long', null, { key, max_length: maxlen });
}
// URL validation using validator library (same as url prop type)
let valid = validator.isURL(value);
if ( ! valid ) {
valid = validator.isURL(value, { host_whitelist: ['localhost'] });
}
if ( ! valid ) {
throw APIError.create('field_invalid', null, { key });
}
};
/**
* Validates a JSON value (must be an object or array).
* @param {*} value - The value to validate
* @param {object} meta - Metadata for the validation
* @param {string} meta.key - The field name (for error messages)
*/
export const validate_json = (value, { key }) => {
if ( typeof value !== 'object' ) {
throw APIError.create('field_invalid', null, { key });
}
};
/**
* Validates an array where each element is a string.
* @param {*} value - The value to validate
* @param {object} meta - Metadata for the validation
* @param {string} meta.key - The field name (for error messages)
*/
export const validate_array_of_strings = (value, { key }) => {
if ( ! Array.isArray(value) ) {
throw APIError.create('field_invalid', null, { key });
}
for ( const item of value ) {
if ( typeof item !== 'string' ) {
throw APIError.create('field_invalid', null, { key });
}
}
};
================================================
FILE: src/backend/src/modules/development/DevelopmentModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
/**
* Enable this module when you want performance monitoring.
*
* Performance monitoring requires additional setup. Jaegar should be installed
* and running.
*/
class DevelopmentModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const LocalTerminalService = require('./LocalTerminalService');
services.registerService('local-terminal', LocalTerminalService);
}
}
module.exports = {
DevelopmentModule,
};
================================================
FILE: src/backend/src/modules/development/LocalTerminalService.js
================================================
/*
* 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 .
*/
const { spawn } = require('child_process');
const APIError = require('../../api/APIError');
const configurable_auth = require('../../middleware/configurable_auth');
const { Endpoint } = require('../../util/expressutil');
const PERM_LOCAL_TERMINAL = 'local-terminal:access';
const path_ = require('path');
const { Actor } = require('../../services/auth/Actor');
const BaseService = require('../../services/BaseService');
const { Context } = require('../../util/context');
class LocalTerminalService extends BaseService {
_construct () {
this.sessions_ = {};
}
get_profiles () {
return {
'api-test': {
cwd: path_.join(__dirname,
'../../../../../',
'tools/api-tester'),
shell: [
'/usr/bin/env', 'node',
'apitest.js',
'--config=config.yml',
],
allow_args: true,
},
};
};
'__on_install.routes' (_, { app }) {
const r_group = (() => {
const require = this.require;
const express = require('express');
return express.Router();
})();
app.use('/local-terminal', r_group);
Endpoint({
route: '/new',
methods: ['POST'],
mw: [configurable_auth()],
handler: async (req, res) => {
const term_uuid = require('uuid').v4();
const svc_permission = this.services.get('permission');
const actor = Context.get('actor');
const can_access = actor &&
await svc_permission.check(actor, PERM_LOCAL_TERMINAL);
if ( ! can_access ) {
throw APIError.create('permission_denied', null, {
permission: PERM_LOCAL_TERMINAL,
});
}
const profiles = this.get_profiles();
if ( ! profiles[req.body.profile] ) {
throw APIError.create('invalid_profile', null, {
profile: req.body.profile,
});
}
const profile = profiles[req.body.profile];
const args = profile.shell.slice(1);
if ( profile.allow_args && req.body.args ) {
args.push(...req.body.args);
}
const proc = spawn(profile.shell[0], args, {
shell: true,
env: {
...process.env,
...(profile.env ?? {}),
},
cwd: profile.cwd,
});
// stdout to websocket
{
const svc_socketio = req.services.get('socketio');
proc.stdout.on('data', data => {
const base64 = data.toString('base64');
console.debug('---------------------- CHUNK?', base64);
svc_socketio.send({ room: req.user.id },
'local-terminal.stdout',
{
term_uuid,
base64,
});
});
proc.stderr.on('data', data => {
const base64 = data.toString('base64');
console.debug('---------------------- CHUNK?', base64);
svc_socketio.send({ room: req.user.id },
'local-terminal.stderr',
{
term_uuid,
base64,
});
});
}
proc.on('exit', () => {
this.log.noticeme(`[${term_uuid}] Process exited (${proc.exitCode})`);
delete this.sessions_[term_uuid];
const svc_socketio = req.services.get('socketio');
svc_socketio.send({ room: req.user.id },
'local-terminal.exit',
{
term_uuid,
});
});
this.sessions_[term_uuid] = {
uuid: term_uuid,
proc,
};
res.json({ term_uuid });
},
}).attach(r_group);
}
async _init () {
const svc_event = this.services.get('event');
svc_event.on('web.socket.user-connected', async (_, {
socket,
user,
}) => {
const svc_permission = this.services.get('permission');
const actor = Actor.adapt(user);
const can_access = actor &&
await svc_permission.check(actor, PERM_LOCAL_TERMINAL);
if ( ! can_access ) {
return;
}
socket.on('local-terminal.stdin', async msg => {
console.log('local term message', msg);
const session = this.sessions_[msg.term_uuid];
if ( ! session ) {
return;
}
const base64 = Buffer.from(msg.data, 'base64');
session.proc.stdin.write(base64);
});
});
}
}
module.exports = LocalTerminalService;
================================================
FILE: src/backend/src/modules/dns/DNSModule.js
================================================
const { AdvancedBase } = require('@heyputer/putility');
class DNSModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const { DNSService } = require('./DNSService');
services.registerService('dns', DNSService);
}
}
module.exports = {
DNSModule,
};
================================================
FILE: src/backend/src/modules/dns/DNSService.js
================================================
const BaseService = require('../../services/BaseService');
const { sleep } = require('../../util/asyncutil');
/**
* DNS service that provides DNS client functionality and optional test server
* @extends BaseService
*/
class DNSService extends BaseService {
/**
* Initializes the DNS service by creating a DNS client and optionally starting a test server
* @returns {Promise}
*/
async _init () {
const dns2 = require('dns2');
// this.dns = new dns2(this.config.client);
this.dns = new dns2({
nameServers: ['127.0.0.1'],
port: 5300,
});
if ( this.config.test_server ) {
this.test_server_();
}
}
/**
* Returns the DNS client instance
* @returns {Object} The DNS client
*/
get_client () {
return this.dns;
}
/**
* Creates and starts a test DNS server that responds to A and TXT record queries
* The server listens on port 5300 and returns mock responses for testing purposes
*/
test_server_ () {
const dns2 = require('dns2');
const { Packet } = dns2;
const server = dns2.createServer({
udp: true,
handle: (request, send, rinfo) => {
const { questions } = request;
const response = Packet.createResponseFromRequest(request);
for ( const question of questions ) {
if ( question.type === Packet.TYPE.A || question.type === Packet.TYPE.ANY ) {
response.answers.push({
name: question.name,
type: Packet.TYPE.A,
class: Packet.CLASS.IN,
ttl: 300,
address: '127.0.0.11',
});
}
if ( question.type === Packet.TYPE.TXT || question.type === Packet.TYPE.ANY ) {
response.answers.push({
name: question.name,
type: Packet.TYPE.TXT,
class: Packet.CLASS.IN,
ttl: 300,
data: [
JSON.stringify({ username: 'ed3' }),
],
});
}
}
send(response);
},
});
server.on('listening', () => {
this.log.debug('Fake DNS server listening', server.addresses());
if ( this.config.test_server_selftest ) {
(async () => {
await sleep(5000);
{
console.log('Trying first test');
const result = await this.dns.resolveA('test.local');
console.log('Test 1', result);
}
{
console.log('Trying second test');
const result = await this.dns.resolve('_puter-verify.test.local', 'TXT');
console.log('Test 2', result);
}
})();
}
});
server.on('close', () => {
console.log('Fake DNS server closed');
});
server.on('request', (request, response, rinfo) => {
console.log(request.header.id, request.questions[0]);
});
server.on('requestError', (error) => {
console.log('Client sent an invalid request', error);
});
server.listen({
udp: {
port: 5300,
address: '127.0.0.1',
},
});
}
}
module.exports = { DNSService };
================================================
FILE: src/backend/src/modules/domain/DomainModule.js
================================================
const { AdvancedBase } = require('@heyputer/putility');
class DomainModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const { DomainVerificationService } = require('./DomainVerificationService');
services.registerService('domain-verification', DomainVerificationService);
// TODO: enable flag
const { TXTVerifyService } = require('./TXTVerifyService');
services.registerService('__txt-verify', TXTVerifyService);
}
}
module.exports = { DomainModule };
================================================
FILE: src/backend/src/modules/domain/DomainVerificationService.js
================================================
const { get_user } = require('../../helpers');
const BaseService = require('../../services/BaseService');
class DomainVerificationService extends BaseService {
_init () {
this._register_commands();
}
async get_controlling_user ({ domain }) {
const svc_event = this.services.get('event');
// 1 :: Allow event listeners to verify domains
const event = {
domain,
user: undefined,
};
await svc_event.emit('domain.get-controlling-user', event);
if ( event.user ) {
return event.user;
}
// 2 :: If there is no controlling user, 'admin' is the
// controlling user.
return await get_user({ username: 'admin' });
}
_register_commands (commands) {
const svc_commands = this.services.get('commands');
svc_commands.registerCommands('domain', [
{
id: 'user',
description: '',
handler: async (args, log) => {
const res = await this.get_controlling_user({ domain: args[0] });
log.log(res);
},
},
]);
}
}
module.exports = {
DomainVerificationService,
};
================================================
FILE: src/backend/src/modules/domain/TXTVerifyService.js
================================================
const { get_user } = require('../../helpers');
const BaseService = require('../../services/BaseService');
const { atimeout } = require('../../util/asyncutil');
class TXTVerifyService extends BaseService {
'__on_boot.consolidation' () {
const svc_dns = this.services.get('dns');
const dns = svc_dns.get_client();
const svc_event = this.services.get('event');
svc_event.on('domain.get-controlling-user', async (_, event) => {
const record_name = `_puter-verify.${event.domain}`;
try {
const result = await atimeout(5000,
dns.resolve(record_name, 'TXT'));
const answer = result.answers.filter(a => a.name === record_name &&
a.type === 16)[0];
const data_raw = answer.data;
const data = JSON.parse(data_raw);
event.user = await get_user({ username: data.username });
} catch (e) {
console.error('ERROR', e);
}
});
}
}
module.exports = {
TXTVerifyService,
};
================================================
FILE: src/backend/src/modules/entitystore/EntityStoreInterfaceService.js
================================================
/*
* Copyright (C) 2025-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 .
*/
const BaseService = require('../../services/BaseService');
/**
* Service class that manages Entity Store interface registrations.
* Handles registration of the crud-q interface which is used by various
* entity storage services.
* @extends BaseService
*/
class EntityStoreInterfaceService extends BaseService {
/**
* Service class for managing Entity Store interface registrations.
* Extends the base service to provide entity storage interface management.
*/
async '__on_driver.register.interfaces' () {
const svc_registry = this.services.get('registry');
const col_interfaces = svc_registry.get('interfaces');
// Define the standard CRUD interface methods that will be reused
const crudMethods = {
create: {
parameters: {
object: {
type: 'json',
subtype: 'object',
required: true,
},
options: { type: 'json' },
},
},
read: {
parameters: {
uid: { type: 'string' },
id: { type: 'json' },
params: { type: 'json' },
},
},
select: {
parameters: {
predicate: { type: 'json' },
offset: { type: 'number' },
limit: { type: 'number' },
params: { type: 'json' },
},
},
update: {
parameters: {
id: { type: 'json' },
object: {
type: 'json',
subtype: 'object',
required: true,
},
options: { type: 'json' },
},
},
upsert: {
parameters: {
id: { type: 'json' },
object: {
type: 'json',
subtype: 'object',
required: true,
},
options: { type: 'json' },
},
},
delete: {
parameters: {
uid: { type: 'string' },
id: { type: 'json' },
},
},
};
// Register the crud-q interface
col_interfaces.set('crud-q', {
methods: { ...crudMethods },
});
// Register entity-specific interfaces that use crud-q
const entityInterfaces = [
{
name: 'puter-apps',
description: 'Manage a developer\'s apps on Puter.',
},
{
name: 'puter-subdomains',
description: 'Manage subdomains on Puter.',
},
{
name: 'puter-notifications',
description: 'Read notifications on Puter.',
},
];
// Register each entity interface with the same CRUD methods
for ( const entity of entityInterfaces ) {
col_interfaces.set(entity.name, {
description: entity.description,
methods: { ...crudMethods },
});
}
}
}
module.exports = {
EntityStoreInterfaceService,
};
================================================
FILE: src/backend/src/modules/entitystore/EntityStoreModule.js
================================================
/*
* Copyright (C) 2025-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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { EntityStoreInterfaceService } = require('./EntityStoreInterfaceService');
/**
* A module for registering entity store interfaces.
*/
class EntityStoreModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
// Register interface services
services.registerService('entitystore-interface', EntityStoreInterfaceService);
}
}
module.exports = {
EntityStoreModule,
};
================================================
FILE: src/backend/src/modules/filesystem/roadmap.md
================================================
## Mountpounts hurdles
- [ ] subdomains use integer IDs to to reference files, which
only works with PuterFS. This means other filesystem
providers will not be usable for subdomains.
Possible solutions:
- GUI logic to disable subdomains feature for other providers
- Add a new column to associate subdomains with paths
- Map non-puterfs nodes to (1B + path_id), where path_id is
a numeric identifier that is associated with the path, and
the association is stored in the database or system runtime
directory.
- [ ] permissions are associated with UUIDs, but will need to
be able to be associated with paths instead for non-puterfs
mountpoints.
- Make path-to-uuid re-writer act on puter-fs only.
- ACL needs to be able to check path-based permissions
on non-puterfs mountpoints.
================================================
FILE: src/backend/src/modules/hostos/HostOSModule.js
================================================
const { AdvancedBase } = require('@heyputer/putility');
class HostOSModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const ProcessService = require('./ProcessService');
services.registerService('process', ProcessService);
}
}
module.exports = {
HostOSModule,
};
================================================
FILE: src/backend/src/modules/hostos/ProcessService.js
================================================
const BaseService = require('../../services/BaseService');
class ProxyLogger {
constructor (log) {
this.log = log;
}
attach (stream) {
let buffer = '';
stream.on('data', (chunk) => {
buffer += chunk.toString();
let lineEndIndex = buffer.indexOf('\n');
while ( lineEndIndex !== -1 ) {
const line = buffer.substring(0, lineEndIndex);
this.log(line);
buffer = buffer.substring(lineEndIndex + 1);
lineEndIndex = buffer.indexOf('\n');
}
});
stream.on('end', () => {
if ( buffer.length ) {
this.log(buffer);
}
});
}
}
class ProcessService extends BaseService {
static CONCERN = 'workers';
static MODULES = {
path: require('path'),
spawn: require('child_process').spawn,
};
_construct () {
this.instances = [];
}
async _init (args) {
this.args = args;
process.on('exit', () => {
this.exit_all_();
});
}
log_ (name, isErr, line) {
let txt = `[${name}:`;
txt += isErr
? '\x1B[34;1m2\x1B[0m'
: '\x1B[32;1m1\x1B[0m';
txt += `] ${ line}`;
this.log.info(txt);
}
async exit_all_ () {
for ( const { proc } of this.instances ) {
proc.kill();
}
}
async start ({ name, fullpath, command, args, env }) {
this.log.info(`Starting ${name} in ${fullpath}`);
const env_processed = { ...(env ?? {}) };
for ( const k in env_processed ) {
if ( typeof env_processed[k] !== 'function' ) continue;
env_processed[k] = env_processed[k]({
global_config: this.global_config,
});
}
this.log.debug('command',
{ command, args });
const proc = this.modules.spawn(command, args, {
shell: true,
env: {
...process.env,
...env_processed,
},
cwd: fullpath,
});
this.instances.push({
name, proc,
});
const out = new ProxyLogger((line) => this.log_(name, false, line));
out.attach(proc.stdout);
const err = new ProxyLogger((line) => this.log_(name, true, line));
err.attach(proc.stderr);
proc.on('exit', () => {
this.log.info(`[${name}:exit] Process exited (${proc.exitCode})`);
this.instances = this.instances.filter((inst) => inst.proc !== proc);
});
}
}
module.exports = ProcessService;
================================================
FILE: src/backend/src/modules/internet/InternetModule.js
================================================
const { AdvancedBase } = require('@heyputer/putility');
const config = require('../../config.js');
class InternetModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
if ( config?.services?.['wisp-relay'] ) {
const WispRelayService = require('./WispRelayService.js');
services.registerService('wisp-relay', WispRelayService);
}
}
}
module.exports = { InternetModule };
================================================
FILE: src/backend/src/modules/internet/WispRelayService.js
================================================
const BaseService = require('../../services/BaseService');
class WispRelayService extends BaseService {
_init () {
const path_ = require('path');
const svc_process = this.services.get('process');
svc_process.start({
name: 'internet.js',
command: this.config.node_path,
fullpath: this.config.wisp_relay_path,
args: ['index.js'],
env: {
PORT: this.config.wisp_relay_port,
WISP_AUTH_SERVER: this.config.origin,
},
});
}
}
module.exports = WispRelayService;
================================================
FILE: src/backend/src/modules/kvstore/KVStoreInterfaceService.js
================================================
/*
* Copyright (C) 2025-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 .
*/
const BaseService = require('../../services/BaseService');
/**
* @typedef {Object} KVStoreInterface
* @property {function(KVStoreGetParams): Promise} get - Retrieve the value(s) for the given key(s).
* @property {function(KVStoreSetParams): Promise} set - Set a value for a key, with optional expiration.
* @property {function(KVStoreDelParams): Promise} del - Delete a value by key.
* @property {function(KVStoreListParams): Promise} list - List key-value pairs, optionally with pagination.
* @property {function(): Promise} flush - Delete all key-value pairs in the store.
* @property {(params: KVStoreUpdateParams) => Promise} update - Update nested values by key.
* @property {(params: KVStoreAddParams) => Promise} add - Append values into list paths by key.
* @property {(params: KVStoreRemoveParams) => Promise} remove - Remove nested values by key.
* @property {(params: {key:string, pathAndAmountMap: Record}) => Promise} incr - Increment a numeric value by key.
* @property {(params: {key:string, pathAndAmountMap: Record}) => Promise} decr - Decrement a numeric value by key.
* @property {function(KVStoreExpireAtParams): Promise} expireAt - Set a key to expire at a specific UNIX timestamp (seconds).
* @property {function(KVStoreExpireParams): Promise} expire - Set a key to expire after a given TTL (seconds).
*
* @typedef {Object} KVStoreGetParams
* @property {string|string[]} key - The key or array of keys to retrieve.
*
* @typedef {Object} KVStoreSetParams
* @property {string} key - The key to set.
* @property {*} value - The value to store.
* @property {number} [expireAt] - Optional UNIX timestamp (seconds) when the key should expire.
*
* @typedef {Object} KVStoreDelParams
* @property {string} key - The key to delete.
*
* @typedef {Object} KVStoreListParams
* @property {string} [as] - Optional type to list as ("keys", "values", or "entries").
* @property {string} [pattern] - Optional key prefix to match.
* @property {number} [limit] - Optional max number of items to return.
* @property {string} [cursor] - Optional cursor to continue listing from.
*
* @typedef {Object} KVStoreListResult
* @property {Array} items - Items in the current page.
* @property {string} [cursor] - Cursor for the next page, if available.
*
* @typedef {Object} KVStoreUpdateParams
* @property {string} key - The key to update.
* @property {Object.} pathAndValueMap - Map of period-joined paths to values.
* @property {number} [ttl] - Optional TTL in seconds for the whole object.
*
* @typedef {Object} KVStoreAddParams
* @property {string} key - The key to update.
* @property {Object.} pathAndValueMap - Map of period-joined paths to values to append.
*
* @typedef {Object} KVStoreRemoveParams
* @property {string} key - The key to update.
* @property {string[]} paths - List of period-joined paths to remove.
*
* @typedef {Object} KVStoreExpireAtParams
* @property {string} key - The key to set expiration for.
* @property {number} timestamp - UNIX timestamp (seconds) when the key should expire.
*
* @typedef {Object} KVStoreExpireParams
* @property {string} key - The key to set expiration for.
* @property {number} ttl - Time-to-live in seconds.
*/
/**
* Service for registering the puter-kvstore interface, exposing a simple key-value store API
* with support for get, set, delete, list, flush, increment, decrement, and key expiration.
* @extends BaseService
*/
class KVStoreInterfaceService extends BaseService {
/**
* Service class for managing KVStore interface registrations.
* Extends the base service to provide key-value store interface management.
*/
async '__on_driver.register.interfaces' () {
const svc_registry = this.services.get('registry');
const col_interfaces = svc_registry.get('interfaces');
// Register the puter-kvstore interface
col_interfaces.set('puter-kvstore', {
description: 'A simple key-value store.',
methods: {
get: {
description: 'Get a value by key.',
parameters: {
key: { type: 'json', required: true },
},
result: { type: 'json' },
},
set: {
description: 'Set a value by key.',
parameters: {
key: { type: 'string', required: true },
value: { type: 'json' },
expireAt: { type: 'number' },
},
result: { type: 'void' },
},
del: {
description: 'Delete a value by key.',
parameters: {
key: { type: 'string' },
},
result: { type: 'void' },
},
list: {
description: 'List key-value pairs with optional pagination.',
parameters: {
as: {
type: 'string',
},
pattern: {
type: 'string',
},
limit: {
type: 'number',
},
cursor: {
type: 'string',
},
},
result: { type: 'json' },
},
flush: {
description: 'Delete all key-value pairs.',
parameters: {},
result: { type: 'void' },
},
update: {
description: 'Update nested values by key.',
parameters: {
key: { type: 'string', required: true },
pathAndValueMap: { type: 'json', required: true, description: 'map of period-joined path to value' },
ttl: { type: 'number', description: 'optional TTL in seconds for the whole object' },
},
result: { type: 'json', description: 'The updated value' },
},
add: {
description: 'Append values into list paths by key.',
parameters: {
key: { type: 'string', required: true },
pathAndValueMap: { type: 'json', required: true, description: 'map of period-joined path to value to append' },
},
result: { type: 'json', description: 'The updated value' },
},
remove: {
description: 'Remove nested values by key.',
parameters: {
key: { type: 'string', required: true },
paths: { type: 'json', required: true, description: 'list of period-joined paths to remove' },
},
result: { type: 'json', description: 'The updated value' },
},
incr: {
description: 'Increment a value by key.',
parameters: {
key: { type: 'string', required: true },
pathAndAmountMap: { type: 'json', required: true, description: 'map of period-joined path to amount to increment by' },
},
result: { type: 'json', description: 'The updated value' },
},
decr: {
description: 'Decrement a value by key.',
parameters: {
key: { type: 'string', required: true },
pathAndAmountMap: { type: 'json', required: true, description: 'map of period-joined path to amount to increment by' },
},
result: { type: 'json', description: 'The updated value' },
},
expireAt: {
description: 'Set a key to expire at a given timestamp in sec.',
parameters: {
key: { type: 'string', required: true },
timestamp: { type: 'number', required: true },
},
result: { type: 'number' },
},
expire: {
description: 'Set a key to expire in ttl many seconds.',
parameters: {
key: { type: 'string', required: true },
ttl: { type: 'number', required: true },
},
result: { type: 'number' },
},
},
});
}
}
module.exports = {
KVStoreInterfaceService,
};
================================================
FILE: src/backend/src/modules/kvstore/KVStoreModule.js
================================================
/*
* Copyright (C) 2025-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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { KVStoreInterfaceService } = require('./KVStoreInterfaceService');
/**
* A module for registering key-value store interfaces.
*/
class KVStoreModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
// Register interface services
services.registerService('kvstore-interface', KVStoreInterfaceService);
}
}
module.exports = {
KVStoreModule,
};
================================================
FILE: src/backend/src/modules/perfmon/TelemetryService.js
================================================
/*
* 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 .
*/
import { SpanStatusCode, trace } from '@opentelemetry/api';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { Resource } from '@opentelemetry/resources';
import { ConsoleMetricExporter, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base';
import { SemanticAttributes, SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import config from '../../config.js';
import BaseService from '../../services/BaseService.js';
export class TelemetryService extends BaseService {
static TRACER_NAME = 'puter-tracer';
static #sharedSdk = null;
static #sharedTracer = null;
static #telemetryStarted = false;
/** @type {import('@opentelemetry/api').Tracer} */
#tracer = null;
constructor (service_resources, ...args) {
super(service_resources, ...args);
const { sdk, tracer } = TelemetryService.#startTelemetry({
serviceConfig: this.config,
});
this.sdk = sdk;
this.#tracer = tracer;
}
_init () {
if ( ! this.#tracer ) {
return;
}
const svc_context = this.services.get('context', { optional: true });
if ( ! svc_context ) {
return;
}
svc_context.register_context_hook('pre_arun', ({ hints, trace_name, callback, replace_callback }) => {
if ( ! trace_name ) return;
if ( ! hints.trace ) return;
replace_callback(async () => {
return await this.#tracer.startActiveSpan(trace_name, async span => {
try {
return await callback();
} catch ( error ) {
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
throw error;
} finally {
span.end();
}
});
});
});
}
static #normalizeRoute (route) {
if ( Array.isArray(route) ) {
for ( const entry of route ) {
if ( typeof entry === 'string' ) {
return entry;
}
}
return undefined;
}
if ( typeof route === 'string' ) {
return route;
}
if ( route instanceof RegExp ) {
return route.toString();
}
}
static #buildRoute (req, route) {
const normalized = TelemetryService.#normalizeRoute(route);
if ( ! normalized ) {
return undefined;
}
const baseUrl = typeof req?.baseUrl === 'string' ? req.baseUrl : '';
const combined = `${baseUrl}${normalized}`;
return combined || normalized;
}
static #applyRouteToSpan (span, req, route) {
if ( ! route ) {
return;
}
span.setAttribute(SemanticAttributes.HTTP_ROUTE, route);
if ( typeof span.updateName === 'function' && req?.method ) {
span.updateName(`HTTP ${req.method} ${route}`);
}
}
static #buildInstrumentationConfig () {
return {
'@opentelemetry/instrumentation-http': {
responseHook: (span, response) => {
const req = response?.req;
const route = TelemetryService.#buildRoute(req, req?.route?.path);
TelemetryService.#applyRouteToSpan(span, req, route);
},
},
'@opentelemetry/instrumentation-express': {
spanNameHook: (info, defaultName) => {
if ( info.layerType !== 'request_handler' ) {
return defaultName;
}
const route = TelemetryService.#buildRoute(info.request, info.route);
if ( !route || !info.request?.method ) {
return defaultName;
}
return `HTTP ${info.request.method} ${route}`;
},
requestHook: (span, info) => {
const route = TelemetryService.#buildRoute(info.request, info.route);
if ( route ) {
span.setAttribute(SemanticAttributes.HTTP_ROUTE, route);
}
},
},
};
}
static #resolveExporterConfig (serviceConfig) {
return config.jaeger ?? serviceConfig?.jaeger;
}
static #getConfiguredExporter (serviceConfig) {
const exporterConfig = TelemetryService.#resolveExporterConfig(serviceConfig);
if ( exporterConfig ) {
return new OTLPTraceExporter(exporterConfig);
}
if ( serviceConfig?.console ) {
return new ConsoleSpanExporter();
}
}
static #getMetricExporter (serviceConfig) {
const exporterConfig = TelemetryService.#resolveExporterConfig(serviceConfig);
if ( exporterConfig ) {
return new OTLPMetricExporter(exporterConfig);
}
if ( serviceConfig?.console ) {
return new ConsoleMetricExporter();
}
}
static #startTelemetry ({ serviceConfig } = {}) {
if ( TelemetryService.#telemetryStarted ) {
return { sdk: TelemetryService.#sharedSdk, tracer: TelemetryService.#sharedTracer };
}
TelemetryService.#telemetryStarted = true;
const effectiveConfig = serviceConfig ?? config.services?.telemetry ?? {};
const traceExporter = TelemetryService.#getConfiguredExporter(effectiveConfig);
const metricExporter = TelemetryService.#getMetricExporter(effectiveConfig);
if ( !traceExporter && !metricExporter ) {
console.log('TelemetryService not configured, skipping initialization.');
return { sdk: null, tracer: null };
}
const resource = Resource.default().merge(
new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'puter-backend',
[SemanticResourceAttributes.SERVICE_VERSION]: '0.1.0',
}));
const sdkConfig = {
resource,
instrumentations: [
getNodeAutoInstrumentations(TelemetryService.#buildInstrumentationConfig()),
],
};
if ( traceExporter ) {
sdkConfig.traceExporter = traceExporter;
}
if ( metricExporter ) {
sdkConfig.metricReader = new PeriodicExportingMetricReader({
exporter: metricExporter,
});
}
TelemetryService.#sharedSdk = new NodeSDK(sdkConfig);
TelemetryService.#sharedSdk.start();
TelemetryService.#sharedTracer = trace.getTracer(TelemetryService.TRACER_NAME);
return { sdk: TelemetryService.#sharedSdk, tracer: TelemetryService.#sharedTracer };
}
}
================================================
FILE: src/backend/src/modules/puterfs/MountpointService.js
================================================
/*
* 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 .
*/
const { RootNodeSelector, NodeUIDSelector, NodeChildSelector, NodePathSelector, try_infer_attributes } = require('../../filesystem/node/selectors');
const BaseService = require('../../services/BaseService');
/**
* This will eventually be a service which manages the storage
* backends for mountpoints.
*
* For the moment, this is a way to access the storage backend
* in situations where ContextInitService isn't able to
* initialize a context.
*/
/**
* @class MountpointService
* @extends BaseService
* @description Service class responsible for managing storage backends for mountpoints.
* Currently provides a temporary solution for accessing storage backend when context
* initialization is not possible. Will be expanded to handle multiple mountpoints
* and their associated storage backends in future implementations.
*/
class MountpointService extends BaseService {
#storage = {};
#mounters = {};
#mountpoints = {};
register_mounter (name, mounter) {
this.#mounters[name] = mounter;
}
async '__on_boot.consolidation' () {
// Emit event for registering filesystem types
const svc_event = this.services.get('event');
const event = {};
event.createFilesystemType = (name, filesystemType) => {
this.#mounters[name] = filesystemType;
};
await svc_event.emit('create.filesystem-types', event);
// Determine mountpoints configuration
const mountpoints = this.config.mountpoints ?? {
'/': {
mounter: 'puterfs',
},
};
// Mount filesystems
for ( const path of Object.keys(mountpoints) ) {
const { mounter: mounter_name, options } =
mountpoints[path];
const mounter = this.#mounters[mounter_name];
if ( ! mounter ) {
throw new Error(`unrecognized filesystem type: ${mounter_name}`);
}
const provider = await mounter.mount({
path,
options,
});
this.#mountpoints[path] = {
provider,
};
}
this.services.emit('filesystem.ready', {
mountpoints: Object.keys(this.#mountpoints),
});
}
async get_provider (selector) {
// If there is only one provider, we don't need to do any of this,
// and that's a big deal because the current implementation requires
// fetching a filesystem entry before we even have operation-level
// transient memoization instantiated.
if ( Object.keys(this.#mountpoints).length === 1 ) {
return Object.values(this.#mountpoints)[0].provider;
}
try_infer_attributes(selector);
if ( selector instanceof RootNodeSelector ) {
return this.#mountpoints['/'].provider;
}
if ( selector instanceof NodeUIDSelector ) {
for ( const { provider } of Object.values(this.#mountpoints) ) {
const result = await provider.quick_check({
selector,
});
if ( result ) {
return provider;
}
}
// No provider found, but we shouldn't throw an error here
// because it's a valid case for a node that doesn't exist.
}
if ( selector instanceof NodeChildSelector ) {
if ( selector.path ) {
return this.get_provider(new NodePathSelector(selector.path));
} else {
return this.get_provider(selector.parent);
}
}
const probe = {};
selector.setPropertiesKnownBySelector(probe);
if ( probe.path ) {
let longest_mount_path = '';
for ( const path of Object.keys(this.#mountpoints) ) {
if ( ! probe.path.startsWith(path) ) {
continue;
}
if ( path.length > longest_mount_path.length ) {
longest_mount_path = path;
}
}
if ( longest_mount_path ) {
return this.#mountpoints[longest_mount_path].provider;
}
}
// Use root mountpoint as fallback
return this.#mountpoints['/'].provider;
}
// Temporary solution - we'll develop this incrementally
set_storage (provider, storage) {
this.#storage[provider] = storage;
}
/**
* Gets the current storage backend instance
* @returns {Object} The storage backend instance
*/
get_storage (provider) {
const storage = this.#storage[provider];
if ( ! storage ) {
throw new Error(`MountpointService.get_storage: storage for provider "${provider}" not found`);
}
return storage;
}
}
module.exports = {
MountpointService,
};
================================================
FILE: src/backend/src/modules/puterfs/PuterFSModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const FSNodeContext = require('../../filesystem/FSNodeContext');
const capabilities = require('../../filesystem/definitions/capabilities');
const selectors = require('../../filesystem/node/selectors');
const { RuntimeModule } = require('../../extension/RuntimeModule');
const { MODE_READ, MODE_WRITE } = require('../../services/fs/FSLockService');
const { UploadProgressTracker } = require('../../filesystem/storage/UploadProgressTracker');
const { PuterPath } = require('../../filesystem/lib/PuterPath');
class PuterFSModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const { RESOURCE_STATUS_PENDING_CREATE } = require('./ResourceService');
// Expose filesystem declarations to extensions
{
const runtimeModule = new RuntimeModule({ name: 'fs' });
runtimeModule.exports = {
capabilities,
selectors,
FSNodeContext,
PuterPath,
lock: {
MODE_READ,
MODE_WRITE,
},
resource: {
RESOURCE_STATUS_PENDING_CREATE,
},
util: {
UploadProgressTracker,
},
};
context.get('runtime-modules').register(runtimeModule);
}
const { ResourceService } = require('./ResourceService');
services.registerService('resourceService', ResourceService);
const { SizeService } = require('./SizeService');
services.registerService('sizeService', SizeService);
const { MountpointService } = require('./MountpointService');
services.registerService('mountpoint', MountpointService);
const { MemoryFSService } = require('./customfs/MemoryFSService');
services.registerService('memoryfs', MemoryFSService);
}
}
module.exports = { PuterFSModule };
================================================
FILE: src/backend/src/modules/puterfs/ResourceService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../services/BaseService');
const {
NodePathSelector,
NodeUIDSelector,
NodeInternalIDSelector,
NodeChildSelector,
} = require('../../filesystem/node/selectors');
const RESOURCE_STATUS_PENDING_CREATE = {};
const RESOURCE_STATUS_PENDING_UPDATE = {};
const RS_DIRECTORY_PENDING_CHILD_INSERT = {};
/**
* ResourceService is a very simple locking mechanism meant
* only to ensure consistency between requests being sent
* to the same server.
*
* For example, if you send an HTTP request to `/write`, and
* then a subsequent HTTP request to `/read`, you would expect
* the newly written file to be available. Therefore, the call
* to `/read` should wait until the write is complete.
*
* At least for now; I'm sure we'll think of a smarter way to
* handle this in the future.
*/
class ResourceService extends BaseService {
_construct () {
this.uidToEntry = {};
this.uidToPath = {};
this.pathToEntry = {};
}
register (entry) {
entry = { ...entry };
if ( ! entry.uid ) {
// TODO: resource service needs logger access
return;
}
entry.freePromise = new Promise((resolve, reject) => {
entry.free = () => {
resolve();
};
});
entry.onFree = entry.freePromise.then.bind(entry.freePromise);
this.log.debug('registering resource', { uid: entry.uid });
this.uidToEntry[entry.uid] = entry;
if ( entry.path ) {
this.uidToPath[entry.uid] = entry.path;
this.pathToEntry[entry.path] = entry;
}
return entry;
}
free (uid) {
this.log.debug('freeing', { uid });
const entry = this.uidToEntry[uid];
if ( ! entry ) return;
delete this.uidToEntry[uid];
if ( this.uidToPath.hasOwnProperty(uid) ) {
const path = this.uidToPath[uid];
delete this.pathToEntry[path];
delete this.uidToPath[uid];
}
entry.free();
}
async waitForResourceByPath (path) {
const entry = this.pathToEntry[path];
if ( ! entry ) {
return;
}
await entry.freePromise;
}
async waitForResourceByUID (uid) {
const entry = this.uidToEntry[uid];
if ( ! entry ) {
return;
}
await entry.freePromise;
}
async waitForResource (selector) {
if ( selector instanceof NodePathSelector ) {
await this.waitForResourceByPath(selector.value);
}
else
if ( selector instanceof NodeUIDSelector ) {
await this.waitForResourceByUID(selector.value);
}
else
if ( selector instanceof NodeInternalIDSelector ) {
// Can't wait intelligently for this
}
if ( selector instanceof NodeChildSelector ) {
await this.waitForResource(selector.parent);
}
}
getResourceInfo (uid) {
if ( ! uid ) return;
return this.uidToEntry[uid];
}
}
module.exports = {
ResourceService,
RESOURCE_STATUS_PENDING_CREATE,
RESOURCE_STATUS_PENDING_UPDATE,
RS_DIRECTORY_PENDING_CHILD_INSERT,
};
================================================
FILE: src/backend/src/modules/puterfs/SizeService.js
================================================
/*
* 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 .
*/
const { get_dir_size, id2path, get_user, invalidate_cached_user_by_id } = require('../../helpers');
const BaseService = require('../../services/BaseService');
const { DB_WRITE } = require('../../services/database/consts');
// TODO: expose to a utility library
class UserParameter {
static async adapt (value) {
if ( typeof value == 'object' ) return value;
const query_object = typeof value === 'number'
? { id: value }
: { username: value };
return await get_user(query_object);
}
}
class SizeService extends BaseService {
_construct () {
this.usages = {};
}
_init () {
this.db = this.services.get('database').get(DB_WRITE, 'filesystem');
}
'__on_boot.consolidate' () {
const svc_commands = this.services.get('commands');
svc_commands.registerCommands('size', [
{
id: 'get-usage',
description: 'get usage for a user',
handler: async (args, log) => {
const user = await UserParameter.adapt(args[0]);
const usage = await this.get_usage(user.id);
log.log(`usage: ${usage} bytes`);
},
},
{
id: 'get-capacity',
description: 'get storage capacity for a user',
handler: async (args, log) => {
const user = await UserParameter.adapt(args[0]);
const capacity = await this.get_storage_capacity(user);
log.log(`capacity: ${capacity} bytes`);
},
},
{
id: 'get-cache-size',
description: 'get the number of cached users',
handler: async (args, log) => {
const size = Object.keys(this.usages).length;
log.log(`cache size: ${size}`);
},
},
]);
}
async get_usage (user_id) {
// if ( this.usages.hasOwnProperty(user_id) ) {
// return this.usages[user_id];
// }
const fsentry = await this.db.read(
'SELECT SUM(size) AS total FROM `fsentries` WHERE `user_id` = ? LIMIT 1',
[user_id],
);
if ( !fsentry[0] || !fsentry[0].total ) {
this.usages[user_id] = 0;
} else {
this.usages[user_id] = parseInt(fsentry[0].total);
}
return this.usages[user_id];
}
async change_usage (user_id, delta) {
const usage = await this.get_usage(user_id);
this.usages[user_id] = usage + delta;
}
// TODO: remove fs arg and update all calls
async add_node_size (fs, node, user, factor = 1) {
let sz;
if ( node.entry.is_dir ) {
if ( node.entry.uuid ) {
sz = await node.fetchSize();
} else {
// very unlikely, but a warning is better than a throw right now
// TODO: remove this once we're sure this is never hit
this.log.warn('add_node_size: node has no uuid :(', node);
sz = await get_dir_size(await id2path(node.mysql_id), user);
}
} else {
sz = node.entry.size;
}
await this.change_usage(user.id, sz * factor);
}
/**
*
* @param {*} user_or_id
* @param {*} param1.exclude_transient - set to `true` to exclude
* paid storage, and other temporary storage grants which are
* not persisted in the `user.free_storage` column.
* @returns
*/
async get_storage_capacity (user_or_id, { exclude_transient } = {}) {
const user = await UserParameter.adapt(user_or_id);
if ( ! this.global_config.is_storage_limited ) {
return this.global_config.available_device_storage;
}
if ( !user.free_storage && user.free_storage !== 0 ) {
return this.global_config.storage_capacity;
}
return exclude_transient
? user.actual_free_storage ?? user.free_storage
: user.free_storage;
}
/**
* Attempt to add storage for a user.
* In the case of an error, this method will fail silently to the caller and
* produce an alarm for further investigation.
*
* @param {*} user_or_id - user id, username, or user object
* @param {*} amount_in_bytes - amount of bytes to add
* @param {*} reason - please specify a reason for the storage increase
* @param {*} param3 - optional fields to add to the audit log
*/
async add_storage (user_or_id, amount_in_bytes, reason, { field_a, field_b } = {}) {
const user = await UserParameter.adapt(user_or_id);
const capacity = await this.get_storage_capacity(user, { exclude_transient: true });
// Audit log
{
const entry = {
user_id: user.id,
user_id_keep: user.id,
amount: amount_in_bytes,
reason,
...(field_a ? { field_a } : {}),
...(field_b ? { field_b } : {}),
};
const fields_ = Object.keys(entry);
const fields = fields_.join(', ');
const placeholders = fields_.map(_ => '?').join(', ');
const values = fields_.map(f => entry[f]);
try {
await this.db.write(
`INSERT INTO storage_audit (${fields}) VALUES (${placeholders})`,
values,
);
} catch (e) {
this.errors.report('size-service.audit-add-storage', {
source: e,
trace: true,
alarm: true,
});
}
}
// Storage increase
{
try {
const res = await this.db.write(
'UPDATE `user` SET `free_storage` = ? WHERE `id` = ? LIMIT 1',
[capacity + amount_in_bytes, user.id],
);
if ( ! res.anyRowsAffected ) {
throw new Error(`add_storage: failed to update user ${user.id}`);
}
} catch (e) {
this.errors.report('size-service.add-storage', {
source: e,
trace: true,
alarm: true,
});
}
invalidate_cached_user_by_id(user.id);
}
}
}
module.exports = {
SizeService,
};
================================================
FILE: src/backend/src/modules/puterfs/customfs/MemoryFSProvider.js
================================================
/*
* 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 .
*/
const FSNodeContext = require('../../../filesystem/FSNodeContext');
const _path = require('path');
const { Context } = require('../../../util/context');
const { v4: uuidv4 } = require('uuid');
const config = require('../../../config');
const {
NodeChildSelector,
NodePathSelector,
NodeUIDSelector,
NodeRawEntrySelector,
RootNodeSelector,
try_infer_attributes,
} = require('../../../filesystem/node/selectors');
const fsCapabilities = require('../../../filesystem/definitions/capabilities');
const APIError = require('../../../api/APIError');
class MemoryFile {
/**
* @param {Object} param
* @param {string} param.path - Relative path from the mountpoint.
* @param {boolean} param.is_dir
* @param {Buffer|null} param.content - The content of the file, `null` if the file is a directory.
* @param {string|null} [param.parent_uid] - UID of parent directory; null for root.
*/
constructor ({ path, is_dir, content, parent_uid = null }) {
this.uuid = uuidv4();
this.is_public = true;
this.path = path;
this.name = _path.basename(path);
this.is_dir = is_dir;
this.content = content;
// parent_uid should reflect the actual parent's uid; null for root
this.parent_uid = parent_uid;
// TODO (xiaochen): return sensible values for "user_id", currently
// it must be 2 (admin) to pass the test.
this.user_id = 2;
// TODO (xiaochen): return sensible values for following fields
this.id = 123;
this.parent_id = 123;
this.immutable = 0;
this.is_shortcut = 0;
this.is_symlink = 0;
this.symlink_path = null;
this.created = Math.floor(Date.now() / 1000);
this.accessed = Math.floor(Date.now() / 1000);
this.modified = Math.floor(Date.now() / 1000);
this.size = is_dir ? 0 : content ? content.length : 0;
}
}
class MemoryFSProvider {
constructor (mountpoint) {
this.mountpoint = mountpoint;
// key: relative path from the mountpoint, always starts with `/`
// value: entry uuid
this.entriesByPath = new Map();
// key: entry uuid
// value: entry (MemoryFile)
//
// We declare 2 maps to support 2 lookup apis: by-path/by-uuid.
this.entriesByUUID = new Map();
const root = new MemoryFile({
path: '/',
is_dir: true,
content: null,
parent_uid: null,
});
this.entriesByPath.set('/', root.uuid);
this.entriesByUUID.set(root.uuid, root);
}
/**
* Get the capabilities of this filesystem provider.
*
* @returns {Set} - Set of capabilities supported by this provider.
*/
get_capabilities () {
return new Set([
fsCapabilities.READDIR_UUID_MODE,
fsCapabilities.UUID,
fsCapabilities.READ,
fsCapabilities.WRITE,
fsCapabilities.COPY_TREE,
]);
}
/**
* Normalize the path to be relative to the mountpoint. Returns `/` if the path is empty/undefined.
*
* @param {string} path - The path to normalize.
* @returns {string} - The normalized path, always starts with `/`.
*/
_inner_path (path) {
if ( ! path ) {
return '/';
}
if ( path.startsWith(this.mountpoint) ) {
path = path.slice(this.mountpoint.length);
}
if ( ! path.startsWith('/') ) {
path = `/${ path}`;
}
return path;
}
/**
* Check the integrity of the whole memory filesystem. Throws error if any violation is found.
*
* @returns {Promise}
*/
_integrity_check () {
if ( config.env !== 'dev' ) {
// only check in debug mode since it's expensive
return;
}
// check the 2 maps are consistent
if ( this.entriesByPath.size !== this.entriesByUUID.size ) {
throw new Error('Path map and UUID map have different sizes');
}
for ( const [inner_path, uuid] of this.entriesByPath ) {
const entry = this.entriesByUUID.get(uuid);
// entry should exist
if ( ! entry ) {
throw new Error(`Entry ${uuid} does not exist`);
}
// path should match
if ( this._inner_path(entry.path) !== inner_path ) {
throw new Error(`Path ${inner_path} does not match entry ${uuid}`);
}
// uuid should match
if ( entry.uuid !== uuid ) {
throw new Error(`UUID ${uuid} does not match entry ${entry.uuid}`);
}
// parent should exist
if ( entry.parent_uid ) {
const parent_entry = this.entriesByUUID.get(entry.parent_uid);
if ( ! parent_entry ) {
throw new Error(`Parent ${entry.parent_uid} does not exist`);
}
}
// parent's path should be a prefix of the entry's path
if ( entry.parent_uid ) {
const parent_entry = this.entriesByUUID.get(entry.parent_uid);
if ( ! entry.path.startsWith(parent_entry.path) ) {
throw new Error(`Parent ${entry.parent_uid} path ${parent_entry.path} is not a prefix of entry ${entry.path}`);
}
}
// parent should be a directory
if ( entry.parent_uid ) {
const parent_entry = this.entriesByUUID.get(entry.parent_uid);
if ( ! parent_entry.is_dir ) {
throw new Error(`Parent ${entry.parent_uid} is not a directory`);
}
}
}
}
/**
* Check if a given node exists.
*
* @param {Object} param
* @param {NodePathSelector | NodeUIDSelector | NodeChildSelector | RootNodeSelector | NodeRawEntrySelector} param.selector - The selector used for checking.
* @returns {Promise} - True if the node exists, false otherwise.
*/
async quick_check ({ selector }) {
if ( selector instanceof NodePathSelector ) {
const inner_path = this._inner_path(selector.value);
return this.entriesByPath.has(inner_path);
}
if ( selector instanceof NodeUIDSelector ) {
return this.entriesByUUID.has(selector.value);
}
// fallback to stat
const entry = await this.stat({ selector });
return !!entry;
}
/**
* Performs a stat operation using the given selector.
*
* NB: Some returned fields currently contain placeholder values. And the
* `path` of the absolute path from the root.
*
* @param {Object} param
* @param {NodePathSelector | NodeUIDSelector | NodeChildSelector | RootNodeSelector | NodeRawEntrySelector} param.selector - The selector to stat.
* @returns {Promise} - The result of the stat operation, or `null` if the node doesn't exist.
*/
async stat ({ selector }) {
try_infer_attributes(selector);
let entry_uuid = null;
if ( selector instanceof NodePathSelector ) {
// stat by path
const inner_path = this._inner_path(selector.value);
entry_uuid = this.entriesByPath.get(inner_path);
} else if ( selector instanceof NodeUIDSelector ) {
// stat by uid
entry_uuid = selector.value;
} else if ( selector instanceof NodeChildSelector ) {
if ( selector.path ) {
// Shouldn't care about about parent when the "path" is present
// since it might have different provider.
return await this.stat({
selector: new NodePathSelector(selector.path),
});
} else {
// recursively stat the parent and then stat the child
const parent_entry = await this.stat({
selector: selector.parent,
});
if ( parent_entry ) {
const full_path = _path.join(parent_entry.path, selector.name);
return await this.stat({
selector: new NodePathSelector(full_path),
});
}
}
} else {
// other selectors shouldn't reach here, i.e., it's an internal logic error
throw APIError.create('invalid_node');
}
const entry = this.entriesByUUID.get(entry_uuid);
if ( ! entry ) {
return null;
}
// Return a copied entry with `full_path`, since external code only cares
// about full path.
const copied_entry = { ...entry };
copied_entry.path = _path.join(this.mountpoint, entry.path);
return copied_entry;
}
/**
* Read directory contents.
*
* @param {Object} param
* @param {Context} param.context - The context of the operation.
* @param {FSNodeContext} param.node - The directory node to read.
* @returns {Promise} - Array of child UUIDs.
*/
async readdir ({ context, node }) {
// prerequistes: get required path via stat
const entry = await this.stat({ selector: node.selector });
if ( ! entry ) {
throw APIError.create('invalid_node');
}
const inner_path = this._inner_path(entry.path);
const child_uuids = [];
// Find all entries that are direct children of this directory
for ( const [path, uuid] of this.entriesByPath ) {
if ( path === inner_path ) {
continue; // Skip the directory itself
}
const dirname = _path.dirname(path);
if ( dirname === inner_path ) {
child_uuids.push(uuid);
}
}
return child_uuids;
}
/**
* Create a new directory.
*
* @param {Object} param
* @param {Context} param.context - The context of the operation.
* @param {FSNodeContext} param.parent - The parent node to create the directory in. Must exist and be a directory.
* @param {string} param.name - The name of the new directory.
* @returns {Promise} - The new directory node.
*/
async mkdir ({ context, parent, name }) {
// prerequistes: get required path via stat
const parent_entry = await this.stat({ selector: parent.selector });
if ( ! parent_entry ) {
throw APIError.create('invalid_node');
}
const full_path = _path.join(parent_entry.path, name);
const inner_path = this._inner_path(full_path);
let entry = null;
if ( this.entriesByPath.has(inner_path) ) {
throw APIError.create('item_with_same_name_exists', null, {
entry_name: full_path,
});
} else {
entry = new MemoryFile({
path: inner_path,
is_dir: true,
content: null,
parent_uid: parent_entry.uuid,
});
this.entriesByPath.set(inner_path, entry.uuid);
this.entriesByUUID.set(entry.uuid, entry);
}
// create the node
const fs = context.get('services').get('filesystem');
const node = await fs.node(new NodeUIDSelector(entry.uuid));
await node.fetchEntry();
this._integrity_check();
return node;
}
/**
* Remove a directory.
*
* @param {Object} param
* @param {Context} param.context
* @param {FSNodeContext} param.node: The directory to remove.
* @param {Object} param.options: The options for the operation.
* @returns {Promise}
*/
async rmdir ({ context, node, options = {} }) {
this._integrity_check();
// prerequistes: get required path via stat
const entry = await this.stat({ selector: node.selector });
if ( ! entry ) {
throw APIError.create('invalid_node');
}
const inner_path = this._inner_path(entry.path);
// for mode: non-recursive
if ( ! options.recursive ) {
const children = await this.readdir({ context, node });
if ( children.length > 0 ) {
throw APIError.create('not_empty');
}
}
// remove all descendants
for ( const [other_inner_path, other_entry_uuid] of this.entriesByPath ) {
if ( other_entry_uuid === entry.uuid ) {
// skip the directory itself
continue;
}
if ( other_inner_path.startsWith(inner_path) ) {
this.entriesByPath.delete(other_inner_path);
this.entriesByUUID.delete(other_entry_uuid);
}
}
// for mode: non-descendants-only
if ( ! options.descendants_only ) {
// remove the directory itself
this.entriesByPath.delete(inner_path);
this.entriesByUUID.delete(entry.uuid);
}
this._integrity_check();
}
/**
* Remove a file.
*
* @param {Object} param
* @param {Context} param.context
* @param {FSNodeContext} param.node: The file to remove.
* @returns {Promise}
*/
async unlink ({ context, node }) {
// prerequistes: get required path via stat
const entry = await this.stat({ selector: node.selector });
if ( ! entry ) {
throw APIError.create('invalid_node');
}
const inner_path = this._inner_path(entry.path);
this.entriesByPath.delete(inner_path);
this.entriesByUUID.delete(entry.uuid);
}
/**
* Move a file.
*
* @param {Object} param
* @param {Context} param.context
* @param {FSNodeContext} param.node: The file to move.
* @param {FSNodeContext} param.new_parent: The new parent directory of the file.
* @param {string} param.new_name: The new name of the file.
* @param {Object} param.metadata: The metadata of the file.
* @returns {Promise}
*/
async move ({ context, node, new_parent, new_name, metadata }) {
// prerequistes: get required path via stat
const new_parent_entry = await this.stat({ selector: new_parent.selector });
if ( ! new_parent_entry ) {
throw APIError.create('invalid_node');
}
// create the new entry
const new_full_path = _path.join(new_parent_entry.path, new_name);
const new_inner_path = this._inner_path(new_full_path);
const entry = new MemoryFile({
path: new_inner_path,
is_dir: node.entry.is_dir,
content: node.entry.content,
parent_uid: new_parent_entry.uuid,
});
entry.uuid = node.entry.uuid;
this.entriesByPath.set(new_inner_path, entry.uuid);
this.entriesByUUID.set(entry.uuid, entry);
// remove the old entry
const inner_path = this._inner_path(node.path);
this.entriesByPath.delete(inner_path);
// NB: should not delete the entry by uuid because uuid does not change
// after the move.
this._integrity_check();
return entry;
}
/**
* Copy a tree of files and directories.
*
* @param {Object} param
* @param {Context} param.context
* @param {FSNodeContext} param.source - The source node to copy.
* @param {FSNodeContext} param.parent - The parent directory for the copy.
* @param {string} param.target_name - The name for the copied item.
* @returns {Promise} - The copied node.
*/
async copy_tree ({ context, source, parent, target_name }) {
const fs = context.get('services').get('filesystem');
if ( source.entry.is_dir ) {
// Create the directory
const new_dir = await this.mkdir({ context, parent, name: target_name });
// Copy all children
const children = await this.readdir({ context, node: source });
for ( const child_uuid of children ) {
const child_node = await fs.node(new NodeUIDSelector(child_uuid));
await child_node.fetchEntry();
const child_name = child_node.entry.name;
await this.copy_tree({
context,
source: child_node,
parent: new_dir,
target_name: child_name,
});
}
return new_dir;
} else {
// Copy the file
const new_file = await this.write_new({
context,
parent,
name: target_name,
file: { stream: { read: () => source.entry.content } },
});
return new_file;
}
}
/**
* Write a new file to the filesystem. Throws an error if the destination
* already exists.
*
* @param {Object} param
* @param {Context} param.context
* @param {FSNodeContext} param.parent: The parent directory of the destination directory.
* @param {string} param.name: The name of the destination directory.
* @param {Object} param.file: The file to write.
* @returns {Promise}
*/
async write_new ({ context, parent, name, file }) {
// prerequistes: get required path via stat
const parent_entry = await this.stat({ selector: parent.selector });
if ( ! parent_entry ) {
throw APIError.create('invalid_node');
}
const full_path = _path.join(parent_entry.path, name);
const inner_path = this._inner_path(full_path);
let entry = null;
if ( this.entriesByPath.has(inner_path) ) {
throw APIError.create('item_with_same_name_exists', null, {
entry_name: full_path,
});
} else {
entry = new MemoryFile({
path: inner_path,
is_dir: false,
content: file.stream.read(),
parent_uid: parent_entry.uuid,
});
this.entriesByPath.set(inner_path, entry.uuid);
this.entriesByUUID.set(entry.uuid, entry);
}
const fs = context.get('services').get('filesystem');
const node = await fs.node(new NodeUIDSelector(entry.uuid));
await node.fetchEntry();
this._integrity_check();
return node;
}
/**
* Overwrite an existing file. Throws an error if the destination does not
* exist.
*
* @param {Object} param
* @param {Context} param.context
* @param {FSNodeContext} param.node: The node to write to.
* @param {Object} param.file: The file to write.
* @returns {Promise}
*/
async write_overwrite ({ context, node, file }) {
const entry = await this.stat({ selector: node.selector });
if ( ! entry ) {
throw APIError.create('invalid_node');
}
const inner_path = this._inner_path(entry.path);
this.entriesByPath.set(inner_path, entry.uuid);
let original_entry = this.entriesByUUID.get(entry.uuid);
if ( ! original_entry ) {
throw new Error(`File ${entry.path} does not exist`);
} else {
if ( original_entry.is_dir ) {
throw new Error('Cannot overwrite a directory');
}
original_entry.content = file.stream.read();
original_entry.modified = Math.floor(Date.now() / 1000);
original_entry.size = original_entry.content ? original_entry.content.length : 0;
this.entriesByUUID.set(entry.uuid, original_entry);
}
const fs = context.get('services').get('filesystem');
node = await fs.node(new NodeUIDSelector(original_entry.uuid));
await node.fetchEntry();
this._integrity_check();
return node;
}
async read ({
context,
node,
}) {
// TODO: once MemoryFS aggregates its own storage, don't get it
// via mountpoint service.
const svc_mountpoint = context.get('services').get('mountpoint');
const storage = svc_mountpoint.get_storage(this.constructor.name);
const stream = (await storage.create_read_stream(await node.get('uid'), {
memory_file: node.entry,
}));
return stream;
}
}
module.exports = {
MemoryFSProvider,
};
================================================
FILE: src/backend/src/modules/puterfs/customfs/MemoryFSService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../../services/BaseService');
const { MemoryFSProvider } = require('./MemoryFSProvider');
class MemoryFSService extends BaseService {
async _init () {
const svc_mountpoint = this.services.get('mountpoint');
svc_mountpoint.register_mounter('memoryfs', this.as('mounter'));
}
static IMPLEMENTS = {
mounter: {
async mount ({ path, options }) {
const provider = new MemoryFSProvider(path);
return provider;
},
},
};
}
module.exports = {
MemoryFSService,
};
================================================
FILE: src/backend/src/modules/puterfs/customfs/README.md
================================================
# Custom FS Providers
This directory contains custom FS providers that are not part of the core PuterFS.
## MemoryFSProvider
This is a demo FS provider that illustrates how to implement a custom FS provider.
## NullFSProvider
A FS provider that mimics `/dev/null`.
## LinuxFSProvider
Provide the ability to mount a Linux directory as a FS provider.
================================================
FILE: src/backend/src/modules/selfhosted/DefaultUserService.js
================================================
/*
* 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 .
*/
const { QuickMkdir } = require('../../filesystem/hl_operations/hl_mkdir');
const { HLWrite } = require('../../filesystem/hl_operations/hl_write');
const { NodePathSelector } = require('../../filesystem/node/selectors');
const { get_user, invalidate_cached_user } = require('../../helpers');
const { Context } = require('../../util/context');
const { buffer_to_stream } = require('../../util/streamutil');
const BaseService = require('../../services/BaseService');
const { Actor, UserActorType } = require('../../services/auth/Actor');
const { DB_WRITE } = require('../../services/database/consts');
const { quot } = require('@heyputer/putility').libs.string;
const bcrypt = require('bcrypt');
const uuidv4 = require('uuid').v4;
const crypto = require('crypto');
const USERNAME = 'admin';
const DEFAULT_FILES = {};
class DefaultUserService extends BaseService {
async _init () {
this._register_commands(this.services.get('commands'));
}
async '__on_ready.webserver' () {
// check if a user named `admin` exists
let user = await get_user({ username: USERNAME, cached: false });
if ( ! user ) {
user = await this.create_default_user_();
} else {
await this.#createDefaultUserFiles(Actor.adapt(user));
}
// check if user named `admin` is using default password
const tmp_password = await this.get_tmp_password_(user);
const is_default_password = await bcrypt.compare(
tmp_password,
user.password,
);
if ( ! is_default_password ) return;
// console.log(`password for admin is: ${tmp_password}`);
// NB: this is needed for the CI to extract the password
console.log(`password for admin is: ${tmp_password}`);
const realConsole = globalThis.original_console_object ?? console;
realConsole.log('\n************************************************************');
realConsole.log('* Your default login credentials are:');
realConsole.log('* Username: admin');
realConsole.log(`* Password: ${tmp_password}`);
realConsole.log('* (change the password to remove this message)');
realConsole.log('************************************************************\n');
}
async create_default_user_ () {
const db = this.services.get('database').get(DB_WRITE, USERNAME);
await db.write(
`
INSERT INTO user (uuid, username, free_storage)
VALUES (?, ?, ?)
`,
[
uuidv4(),
USERNAME,
1024 * 1024 * 1024 * 10, // 10 GB
],
);
const svc_group = this.services.get('group');
await svc_group.add_users({
uid: 'ca342a5e-b13d-4dee-9048-58b11a57cc55', // admin
users: [USERNAME],
});
const user = await get_user({ username: USERNAME, cached: false });
const actor = Actor.adapt(user);
const tmp_password = await this.get_tmp_password_(user);
const password_hashed = await bcrypt.hash(tmp_password, 8);
await db.write(
'UPDATE user SET password = ? WHERE id = ?',
[
password_hashed,
user.id,
],
);
user.password = password_hashed;
const svc_user = this.services.get('user');
await svc_user.generate_default_fsentries({ user });
// generate default files for admin user
await this.#createDefaultUserFiles(actor);
invalidate_cached_user(user);
await new Promise(rslv => setTimeout(rslv, 2000));
return user;
}
async #recursiveCreateDefaultFilesIfMissing ({ components, tree, actor }) {
const svc_fs = this.services.get('filesystem');
const parent = await svc_fs.node(new NodePathSelector(`/${components.join('/')}`));
for ( const k in tree ) {
if ( typeof tree[k] === 'string' ) {
try {
const buffer = Buffer.from(tree[k], 'utf-8');
const hl_write = new HLWrite();
await hl_write.run({
destination_or_parent: parent,
specified_name: k,
file: {
size: buffer.length,
stream: buffer_to_stream(buffer),
},
actor,
});
} catch (e) {
if ( e.message.includes('already exists.') ) {
// ignore
} else {
// throw if it actually fails to create the files
throw e;
}
}
} else {
try {
const hl_qmkdir = new QuickMkdir();
await hl_qmkdir.run({
parent,
path: k,
actor,
});
} catch (e) {
if ( e.message.includes('already exists.') ) {
// ignore
} else {
// throw if it actually fails to create the files
throw e;
}
}
const components_ = [...components, k];
await this.#recursiveCreateDefaultFilesIfMissing({
components: components_,
tree: tree[k],
actor,
});
}
}
};
async #createDefaultUserFiles (actor) {
await this.services.get('su').sudo(actor, async () => {
await this.#recursiveCreateDefaultFilesIfMissing({
components: ['admin'],
tree: DEFAULT_FILES,
actor,
});
});
}
async get_tmp_password_ (user) {
const actor = await Actor.create(UserActorType, { user });
return await Context.get().sub({ actor }).arun(async () => {
const svc_driver = this.services.get('driver');
const driver_response = await svc_driver.call({
iface: 'puter-kvstore',
method: 'get',
args: { key: 'tmp_password' },
});
if ( driver_response.result ) return driver_response.result;
const tmp_password = crypto.randomBytes(4).toString('hex');
await svc_driver.call({
iface: 'puter-kvstore',
method: 'set',
args: {
key: 'tmp_password',
value: tmp_password,
},
});
return tmp_password;
});
}
async force_tmp_password_ (user) {
const db = this.services.get('database')
.get(DB_WRITE, 'terminal-password-reset');
const actor = await Actor.create(UserActorType, { user });
return await Context.get().sub({ actor }).arun(async () => {
const svc_driver = this.services.get('driver');
const tmp_password = crypto.randomBytes(4).toString('hex');
const password_hashed = await bcrypt.hash(tmp_password, 8);
await svc_driver.call({
iface: 'puter-kvstore',
method: 'set',
args: {
key: 'tmp_password',
value: tmp_password,
},
});
await db.write(
'UPDATE user SET password = ? WHERE id = ?',
[
password_hashed,
user.id,
],
);
return tmp_password;
});
}
_register_commands (commands) {
commands.registerCommands('default-user', [
{
id: 'reset-password',
handler: async (args, ctx) => {
const [username] = args;
const user = await get_user({ username });
const tmp_pwd = await this.force_tmp_password_(user);
ctx.log(`New password for ${quot(username)} is: ${tmp_pwd}`);
},
},
]);
}
}
module.exports = DefaultUserService;
================================================
FILE: src/backend/src/modules/selfhosted/DevCreditService.js
================================================
const BaseService = require('../../services/BaseService');
/**
* PermissiveCreditService listens to the event where DriverService asks
* for a credit context, and always provides one that allows use of
* cost-incurring services for no charge. This grants free use to
* everyone to services that incur a cost, as long as the user has
* permission to call the respective service.
*/
class PermissiveCreditService extends BaseService {
static MODULES = {
uuidv4: require('uuid').v4,
};
_init () {
// Maps usernames to simulated credit amounts
// (used when config.simulated_credit is set)
this.simulated_credit_ = {};
const svc_event = this.services.get('event');
svc_event.on('credit.check-available', (_, event) => {
const username = event.actor.type.user.username;
event.available = this.get_user_credit_(username);
// Useful for testing with Dall-E
// event.available = 4 * Math.pow(10,6);
// Useful for testing with Polly
// event.available = 9000;
// Useful for testing judge0
// event.available = 50_000;
// event.avaialble = 49_999;
// Useful for testing ConvertAPI
// event.available = 4_500_000;
// event.available = 4_499_999;
// Useful for testing with textract
// event.available = 150_000;
// event.available = 149_999;
});
svc_event.on('usages.query', (_, event) => {
const username = event.actor.type.user.username;
if ( ! this.config.simulated_credit ) {
event.usages.push({
id: 'dev-credit',
name: 'Unlimited Credit',
used: 0,
available: 1,
});
return;
}
event.usages.push({
id: 'dev-credit',
name: `Simulated Credit (${this.config.simulated_credit})`,
used: this.config.simulated_credit -
this.get_user_credit_(username),
available: this.config.simulated_credit,
});
});
}
get_user_credit_ (username) {
if ( ! this.config.simulated_credit ) {
return Number.MAX_SAFE_INTEGER;
}
return this.simulated_credit_[username] ??
(this.simulated_credit_[username] = this.config.simulated_credit);
}
consume_user_credit_ (username, amount) {
if ( ! this.config.simulated_credit ) return;
if ( ! this.simulated_credit_[username] ) {
this.simulated_credit_[username] = this.config.simulated_credit;
}
this.simulated_credit_[username] -= amount;
}
}
module.exports = PermissiveCreditService;
================================================
FILE: src/backend/src/modules/selfhosted/DevWatcherService.js
================================================
/*
* 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 .
*/
const { webpack, web } = require('webpack');
const BaseService = require('../../services/BaseService');
const path_ = require('node:path');
const fs = require('node:fs');
const url = require('node:url');
class ProxyLogger {
constructor (log) {
this.log = log;
}
attach (stream) {
let buffer = '';
stream.on('data', (chunk) => {
buffer += chunk.toString();
let lineEndIndex = buffer.indexOf('\n');
while ( lineEndIndex !== -1 ) {
const line = buffer.substring(0, lineEndIndex);
this.log(line);
buffer = buffer.substring(lineEndIndex + 1);
lineEndIndex = buffer.indexOf('\n');
}
});
stream.on('end', () => {
if ( buffer.length ) {
this.log(buffer);
}
});
}
}
/**
* @description
* This service is used to run webpack watchers.
*/
class DevWatcherService extends BaseService {
static MODULES = {
path: require('path'),
spawn: require('child_process').spawn,
};
async _init (args) {
this.args = args;
}
// Oh geez we need to wait for the web server to initialize
// so that `config.origin` has the actual port in it if the
// port is set to `auto` - you have no idea how confusing
// this was to debug the first time, like Ahhhhhh!!
// but hey at least we have this convenient event listener.
async '__on_ready.webserver' () {
const svc_process = this.services.get('process');
let { root, commands, webpack } = this.args;
if ( ! webpack ) webpack = [];
let promises = [];
for ( const entry of commands ) {
const { directory } = entry;
const fullpath = this.modules.path.join(root, directory);
// promises.push(this.start_({ ...entry, fullpath }));
promises.push(svc_process.start({ ...entry, fullpath }));
}
for ( const entry of webpack ) {
const p = this.start_a_webpack_watcher_(entry);
promises.push(p);
}
await Promise.all(promises);
// It's difficult to tell when webpack is "done" its first
// run so we just wait a bit before we say we're ready.
await new Promise((resolve) => setTimeout(resolve, 5000));
}
async get_configjs ({ directory, configIsFor, possibleConfigNames }) {
let configjsPath, moduleType;
for ( const [configName, supposedModuleType] of possibleConfigNames ) {
// There isn't really an async fs.exists() funciton. I assume this
// is because 'exists' is already a very fast operation.
const supposedPath = path_.join(this.args.root, directory, configName);
if ( fs.existsSync(supposedPath) ) {
configjsPath = supposedPath;
moduleType = supposedModuleType;
break;
}
}
if ( ! configjsPath ) {
throw new Error(`could not find ${configIsFor} config for: ${directory}`);
}
// If the webpack config ends with .js it could be an ES6 module or a
// CJS module, so the absolute safest thing to do so as not to completely
// break in specific patch version of supported versions of node.js is
// to read the package.json and see what it says is the import mechanism.
if ( moduleType === 'package.json' ) {
const packageJSONPath = path_.join(this.args.root, directory, 'package.json');
const packageJSONObject = JSON.parse(fs.readFileSync(packageJSONPath));
moduleType = packageJSONObject?.type ?? 'module';
}
return {
configjsPath,
moduleType,
};
}
async start_a_webpack_watcher_ (entry) {
const possibleConfigNames = [
['webpack.config.js', 'package.json'],
['webpack.config.cjs', 'commonjs'],
['webpack.config.mjs', 'module'],
];
let {
configjsPath: webpackConfigPath,
moduleType,
} = await this.get_configjs({
directory: entry.directory,
configIsFor: 'webpack', // for error message
possibleConfigNames,
});
let oldEnv;
if ( entry.env ) {
oldEnv = process.env;
const newEnv = Object.create(process.env);
let global_config = null;
try {
const svc_config = this.services.get('config');
global_config = svc_config ? svc_config.get('global_config') : null;
} catch (e) {
// Config service not available yet, will use null
}
for ( const k in entry.env ) {
const envValue = entry.env[k];
// If it's a function, call it with the config, otherwise use the value directly
if ( typeof envValue === 'function' ) {
try {
const result = envValue({ global_config: global_config });
// Only set the env var if we got a non-empty result
// This allows the webpack config to use its fallback values
if ( result ) {
newEnv[k] = result;
}
} catch (e) {
// If config is not available yet, don't set the env var
// This allows the webpack config to use its fallback values from config files
// Only log if it's not a null/undefined access error (which is expected)
if ( !e.message.includes('Cannot read properties of null') &&
!e.message.includes('Cannot read properties of undefined') ) {
this.log.warn(`Could not evaluate env function for ${k}: ${e.message}`);
}
}
} else {
newEnv[k] = envValue;
}
}
process.env = newEnv; // Yep, it totally lets us do this
}
if ( moduleType === 'module' && process.platform === 'win32' ) {
webpackConfigPath = url.pathToFileURL(webpackConfigPath).href;
}
let webpackConfig = moduleType === 'module'
? (await import(webpackConfigPath)).default
: require(webpackConfigPath);
// The webpack config can sometimes be a function
if ( typeof webpackConfig === 'function' ) {
webpackConfig = await webpackConfig();
}
if ( oldEnv ) process.env = oldEnv;
webpackConfig.context = webpackConfig.context
? path_.resolve(path_.join(this.args.root, entry.directory), webpackConfig.context)
: path_.join(this.args.root, entry.directory);
if ( entry.onConfig ) entry.onConfig(webpackConfig);
const webpacker = webpack(webpackConfig);
let errorAfterLastEnd = false;
let firstEvent = true;
webpacker.watch({}, (err, stats) => {
let hideSuccess = false;
if ( firstEvent ) {
firstEvent = false;
hideSuccess = true;
}
if ( err || stats.hasErrors() ) {
// Extract error information without serializing the entire stats object
const errorInfo = {
err: err ? err.message : null,
errors: stats.compilation?.errors?.map(e => e.message) || [],
warnings: stats.compilation?.warnings?.map(w => w.message) || [],
};
this.log.error(`error information: ${entry.directory} using Webpack`, errorInfo);
this.log.error(`❌ failed to update ${entry.directory} using Webpack`);
} else {
// Normally success messages aren't important, but sometimes it takes
// a little bit for the bundle to update so a developer probably would
// like to have a visual indication in the console when it happens.
if ( ! hideSuccess ) {
this.log.info(`✅ updated ${entry.directory} using Webpack`);
}
}
});
}
};
module.exports = DevWatcherService;
================================================
FILE: src/backend/src/modules/selfhosted/SelfHostedModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const config = require('../../config');
class SelfHostedModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const { SelfhostedService } = require('./SelfhostedService');
services.registerService('__selfhosted', SelfhostedService);
const DefaultUserService = require('./DefaultUserService');
services.registerService('__default-user', DefaultUserService);
const DevWatcherService = require('./DevWatcherService');
const path_ = require('path');
const DevCreditService = require('./DevCreditService');
services.registerService('dev-credit', DevCreditService);
// TODO: sucks
const RELATIVE_PATH = '../../../../../';
if ( ! config.no_devwatch )
{
services.registerService('__dev-watcher', DevWatcherService, {
root: path_.resolve(__dirname, RELATIVE_PATH),
webpack: [
{
name: 'puter.js',
directory: 'src/puter-js',
onConfig: config => {
config.output.filename = 'puter.dev.js';
config.devtool = 'source-map';
},
env: {
PUTER_ORIGIN: ({ global_config: config }) => config?.origin || '',
PUTER_API_ORIGIN: ({ global_config: config }) => config?.api_base_url || '',
},
},
{
name: 'gui',
directory: 'src/gui',
},
],
commands: [
],
});
}
const { ServeStaticFilesService } = require('./ServeStaticFilesService');
services.registerService('__serve-puterjs', ServeStaticFilesService, {
directories: [
{
prefix: '/sdk',
path: path_.resolve(__dirname, RELATIVE_PATH, 'src/puter-js/dist'),
},
{
prefix: '/builtin/git',
path: path_.resolve(__dirname, RELATIVE_PATH, 'src/git/dist'),
},
{
prefix: '/builtin/dev-center',
path: path_.resolve(__dirname, RELATIVE_PATH, 'src/dev-center'),
},
{
prefix: '/builtin/dev-center',
path: path_.resolve(__dirname, RELATIVE_PATH, 'src/dev-center'),
},
{
prefix: '/vendor/v86/bios',
path: path_.resolve(__dirname, RELATIVE_PATH, 'submodules/v86/bios'),
},
{
prefix: '/vendor/v86',
path: path_.resolve(__dirname, RELATIVE_PATH, 'submodules/v86/build'),
},
],
});
const { ServeSingleFileService } = require('./ServeSingeFileService');
services.registerService('__serve-puterjs-new', ServeSingleFileService, {
path: path_.resolve(__dirname,
RELATIVE_PATH,
'src/puter-js/dist/puter.dev.js'),
route: '/puter.js/v2',
});
services.registerService('__serve-putilityjs-new', ServeSingleFileService, {
path: path_.resolve(__dirname,
RELATIVE_PATH,
'src/putility/dist/putility.dev.js'),
route: '/putility.js/v1',
});
services.registerService('__serve-gui-js', ServeSingleFileService, {
path: path_.resolve(__dirname,
RELATIVE_PATH,
'src/gui/dist/gui.dev.js'),
route: '/putility.js/v1',
});
}
}
module.exports = SelfHostedModule;
================================================
FILE: src/backend/src/modules/selfhosted/SelfhostedService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../services/BaseService');
const { DB_WRITE } = require('../../services/database/consts');
class SelfhostedService extends BaseService {
static description = `
Registers drivers for self-hosted Puter instances.
`;
async _init () {
this._register_commands(this.services.get('commands'));
}
_register_commands (commands) {
const db = this.services.get('database').get(DB_WRITE, 'selfhosted');
commands.registerCommands('app', [
{
id: 'godmode-on',
description: 'Toggle godmode for an app',
handler: async (args, _log) => {
const svc_su = this.services.get('su');
await await svc_su.sudo(async () => {
const [app_uid] = args;
const es_app = await this.services.get('es:app');
const app = await es_app.read(app_uid);
if ( ! app ) {
throw new Error(`App ${app_uid} not found`);
}
await db.write('UPDATE apps SET godmode = 1 WHERE uid = ?', [app_uid]);
const svc_event = this.services.get('event');
await svc_event.emit('app.changed', {
app_uid,
action: 'updated',
});
});
},
},
]);
commands.registerCommands('app', [
{
id: 'godmode-off',
description: 'Toggle godmode for an app',
handler: async (args, _log) => {
const svc_su = this.services.get('su');
await await svc_su.sudo(async () => {
const [app_uid] = args;
const es_app = await this.services.get('es:app');
const app = await es_app.read(app_uid);
if ( ! app ) {
throw new Error(`App ${app_uid} not found`);
}
await db.write('UPDATE apps SET godmode = 0 WHERE uid = ?', [app_uid]);
const svc_event = this.services.get('event');
await svc_event.emit('app.changed', {
app_uid,
action: 'updated',
});
});
},
},
]);
}
}
module.exports = { SelfhostedService };
================================================
FILE: src/backend/src/modules/selfhosted/ServeSingeFileService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../services/BaseService');
class ServeSingleFileService extends BaseService {
async _init (args) {
this.route = args.route;
this.path = args.path;
}
async '__on_install.routes' () {
const { app } = this.services.get('web-server');
app.get(this.route, (req, res) => {
return res.sendFile(this.path);
});
}
}
module.exports = {
ServeSingleFileService,
};
================================================
FILE: src/backend/src/modules/selfhosted/ServeStaticFilesService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../services/BaseService');
class ServeStaticFilesService extends BaseService {
async _init (args) {
this.directories = args.directories;
}
async '__on_install.routes' () {
const { app } = this.services.get('web-server');
for ( const { prefix, path } of this.directories ) {
app.use(prefix, require('express').static(path));
}
}
}
module.exports = { ServeStaticFilesService };
================================================
FILE: src/backend/src/modules/template/README.md
================================================
# TemplateModule
This is a template module that you can copy and paste to create new modules.
This module is also included in `EssentialModules`, which means it will load
when Puter boots. If you're just testing something, you can add it here
temporarily.
## Services
### TemplateService
This is a template service that you can copy and paste to create new services.
You can also add to this service temporarily to test something.
#### Listeners
##### `install.routes`
TemplateService listens to this event to provide an example endpoint
##### `boot.consolidation`
TemplateService listens to this event to provide an example event
##### `boot.activation`
TemplateService listens to this event to show you that it's here
##### `start.webserver`
TemplateService listens to this event to show you that it's here
## Libraries
### hello_world
#### Functions
##### `hello_world`
This is a simple function that returns a string.
You can probably guess what string it returns.
## Notes
### Outside Imports
This module has external relative imports. When these are
removed it may become possible to move this module to an
extension.
**Imports:**
- `../../util/context.js`
- `../../services/BaseService` (use.BaseService)
- `../../util/expressutil`
================================================
FILE: src/backend/src/modules/template/TemplateModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
/**
* This is a template module that you can copy and paste to create new modules.
*
* This module is also included in `EssentialModules`, which means it will load
* when Puter boots. If you're just testing something, you can add it here
* temporarily.
*/
class TemplateModule extends AdvancedBase {
async install (context) {
// === LIBS === //
const useapi = context.get('useapi');
const lib = require('./lib/__lib__.js');
// In extensions: use('workinprogress').hello_world();
// In services classes: see TemplateService.js
useapi.def('workinprogress', lib, { assign: true });
useapi.def('core.context', require('../../util/context.js').Context);
// === SERVICES === //
const services = context.get('services');
const { TemplateService } = require('./TemplateService.js');
services.registerService('template-service', TemplateService);
}
}
module.exports = {
TemplateModule,
};
================================================
FILE: src/backend/src/modules/template/TemplateService.js
================================================
/*
* 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 .
*/
// TODO: import via `USE` static member
const BaseService = require('../../services/BaseService');
const { Endpoint } = require('../../util/expressutil');
/**
* This is a template service that you can copy and paste to create new services.
* You can also add to this service temporarily to test something.
*/
class TemplateService extends BaseService {
static USE = {
// - Defined by lib/__lib__.js,
// - Exposed to `useapi` by TemplateModule.js
workinprogress: 'workinprogress',
};
_construct () {
// Use this override to initialize instance variables.
}
async _init () {
// This is where you initialize the service and prepare
// for the consolidation phase.
this.log.info('I am the template service.');
}
/**
* TemplateService listens to this event to provide an example endpoint
*/
'__on_install.routes' (_, { app }) {
this.log.info('TemplateService get the event for installing endpoint.');
Endpoint({
route: '/example-endpoint',
methods: ['GET'],
handler: async (req, res) => {
res.send(this.workinprogress.hello_world());
},
}).attach(app);
// ^ Don't forget to attach the endpoint to the app!
// it's very easy to forget this step.
}
/**
* TemplateService listens to this event to provide an example event
*/
'__on_boot.consolidation' () {
// At this stage, all services have been initialized and it is
// safe to start emitting events.
this.log.info('TemplateService sees consolidation boot phase.');
const svc_event = this.services.get('event');
svc_event.on('template-service.hello', (_eventid, event_data) => {
this.log.info('template-service said hello to itself; this is expected', {
event_data,
});
});
svc_event.emit('template-service.hello', {
message: 'Hello all you other services! I am the template service.',
});
}
/**
* TemplateService listens to this event to show you that it's here
*/
'__on_boot.activation' () {
this.log.info('TemplateService sees activation boot phase.');
}
/**
* TemplateService listens to this event to show you that it's here
*/
'__on_start.webserver' () {
this.log.info("TemplateService sees it's time to start web servers.");
}
}
module.exports = {
TemplateService,
};
================================================
FILE: src/backend/src/modules/template/lib/__lib__.js
================================================
/*
* 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 .
*/
module.exports = {
hello_world: require('./hello_world.js'),
};
================================================
FILE: src/backend/src/modules/template/lib/hello_world.js
================================================
/*
* 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 .
*/
/**
* This is a simple function that returns a string.
* You can probably guess what string it returns.
*/
const hello_world = () => {
return 'Hello, world!';
};
module.exports = hello_world;
================================================
FILE: src/backend/src/modules/test-config/TestConfigModule.js
================================================
const { AdvancedBase } = require('@heyputer/putility');
class TestConfigModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const TestConfigUpdateService = require('./TestConfigUpdateService');
services.registerService('__test-config-update', TestConfigUpdateService);
const TestConfigReadService = require('./TestConfigReadService');
services.registerService('__test-config-read', TestConfigReadService);
}
}
module.exports = {
TestConfigModule,
};
================================================
FILE: src/backend/src/modules/test-config/TestConfigReadService.js
================================================
const BaseService = require('../../services/BaseService');
class TestConfigReadService extends BaseService {
async _init () {
this.log.debug(`test config value (should be abcdefg) is: ${
this.global_config.testConfigValue}`);
}
}
module.exports = TestConfigReadService;
================================================
FILE: src/backend/src/modules/test-config/TestConfigUpdateService.js
================================================
const BaseService = require('../../services/BaseService');
class TestConfigUpdateService extends BaseService {
async _run_as_early_as_possible () {
const config = this.global_config;
config.__set_config_object__({
testConfigValue: 'abcdefg',
});
}
}
module.exports = TestConfigUpdateService;
================================================
FILE: src/backend/src/modules/test-core/TestCoreModule.js
================================================
import { DDBClientWrapper } from '../../clients/dynamodb/DDBClientWrapper.js';
import { FilesystemService } from '../../filesystem/FilesystemService.js';
import { AnomalyService } from '../../services/AnomalyService.js';
import { AuthService } from '../../services/auth/AuthService.js';
import { GroupService } from '../../services/auth/GroupService.js';
import { PermissionService } from '../../services/auth/PermissionService.js';
import { TokenService } from '../../services/auth/TokenService.js';
import { CommandService } from '../../services/CommandService.js';
import { SqliteDatabaseAccessService } from '../../services/database/SqliteDatabaseAccessService.js';
import { DetailProviderService } from '../../services/DetailProviderService.js';
import { DynamoKVStoreWrapper } from '../../services/DynamoKVStore/DynamoKVStoreWrapper.js';
import { EventService } from '../../services/EventService.js';
import { FeatureFlagService } from '../../services/FeatureFlagService.js';
import { GetUserService } from '../../services/GetUserService.js';
import { MeteringServiceWrapper } from '../../services/MeteringService/MeteringServiceWrapper.mjs';
import { NotificationService } from '../../services/NotificationService';
import { RegistrantService } from '../../services/RegistrantService';
import { RegistryService } from '../../services/RegistryService';
import { ScriptService } from '../../services/ScriptService';
import { SessionService } from '../../services/SessionService';
import { SUService } from '../../services/SUService';
import { SystemValidationService } from '../../services/SystemValidationService';
import { AlarmService } from '../core/AlarmService';
import APIErrorService from '../web/APIErrorService';
export class TestCoreModule {
async install (context) {
const services = context.get('services');
services.registerService('dynamo', DDBClientWrapper);
services.registerService('whoami', DetailProviderService);
services.registerService('get-user', GetUserService);
services.registerService('database', SqliteDatabaseAccessService);
services.registerService('su', SUService);
services.registerService('alarm', AlarmService);
services.registerService('event', EventService);
services.registerService('commands', CommandService);
services.registerService('meteringService', MeteringServiceWrapper);
services.registerService('puter-kvstore', DynamoKVStoreWrapper);
services.registerService('permission', PermissionService);
services.registerService('group', GroupService);
services.registerService('anomaly', AnomalyService);
services.registerService('api-error', APIErrorService);
services.registerService('system-validation', SystemValidationService);
services.registerService('registry', RegistryService);
services.registerService('__registrant', RegistrantService);
services.registerService('feature-flag', FeatureFlagService);
services.registerService('token', TokenService);
services.registerService('auth', AuthService);
services.registerService('session', SessionService);
services.registerService('notification', NotificationService);
services.registerService('script', ScriptService);
services.registerService('filesystem', FilesystemService);
}
}
================================================
FILE: src/backend/src/modules/test-drivers/TestAssetHostService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../services/BaseService');
class TestAssetHostService extends BaseService {
async '__on_install.routes' () {
const { app } = this.services.get('web-server');
const path_ = require('node:path');
app.use('/test-assets', require('express').static(
path_.join(__dirname, 'assets')));
}
}
module.exports = {
TestAssetHostService,
};
================================================
FILE: src/backend/src/modules/test-drivers/TestDriversModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
class TestDriversModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const { TestAssetHostService } = require('./TestAssetHostService');
services.registerService('__test-assets', TestAssetHostService);
const { TestImageService } = require('./TestImageService');
services.registerService('test-image', TestImageService);
}
}
module.exports = {
TestDriversModule,
};
================================================
FILE: src/backend/src/modules/test-drivers/TestImageService.js
================================================
/*
* 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 .
*/
const config = require('../../config');
const BaseService = require('../../services/BaseService');
const { TypedValue } = require('../../services/drivers/meta/Runtime');
const { buffer_to_stream } = require('../../util/streamutil');
const PUBLIC_DOMAIN_IMAGES = [
{
name: 'starry-night',
url: 'https://upload.wikimedia.org/wikipedia/commons/e/ea/Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg',
file: 'starry.jpg',
},
];
class TestImageService extends BaseService {
async '__on_driver.register.interfaces' () {
const svc_registry = this.services.get('registry');
const col_interfaces = svc_registry.get('interfaces');
col_interfaces.set('test-image', {
methods: {
echo_image: {
parameters: {
source: {
type: 'file',
},
},
result: {
type: {
$: 'stream',
content_type: 'image',
},
},
},
get_image: {
parameters: {
source_type: {
type: 'string',
},
},
result: {
type: {
$: 'stream',
content_type: 'image',
},
},
},
},
});
}
static IMPLEMENTS = {
'version': {
get_version () {
return 'v1.0.0';
},
},
'test-image': {
async echo_image ({
source,
}) {
const stream = await source.get('stream');
return new TypedValue({
$: 'stream',
content_type: 'image/jpeg',
}, stream);
},
async get_image ({
source_type,
}) {
const image = PUBLIC_DOMAIN_IMAGES[0];
if ( source_type === 'string:url:web' ) {
return new TypedValue({
$: 'string:url:web',
content_type: 'image',
}, `${config.origin}/test-assets/${image.file}`);
}
throw new Error('not implemented yet');
},
},
};
}
module.exports = {
TestImageService,
};
================================================
FILE: src/backend/src/modules/test-drivers/doc/requests.md
================================================
```javascript
blob = await (await fetch("http://api.puter.localhost:4100/drivers/call", {
"headers": {
"Content-Type": "application/json",
"Authorization": `Bearer ${puter.authToken}`,
},
"body": JSON.stringify({
interface: 'test-image',
method: 'get_image',
args: {
source_type: 'string:url:web'
}
}),
"method": "POST",
})).blob();
dataurl = await new Promise((y, n) => {
a = new FileReader();
a.onload = _ => y(a.result);
a.onerror = _ => n(a.error);
a.readAsDataURL(blob)
});
URL.createObjectURL(await (await fetch("http://api.puter.localhost:4100/drivers/call", {
"headers": {
"Content-Type": "application/json",
"Authorization": `Bearer ${puter.authToken}`,
},
"body": JSON.stringify({
interface: 'test-image',
method: 'echo_image',
args: {
source: dataurl,
}
}),
"method": "POST",
})).blob());
```
```javascript
await(async () => {
blob = await (await fetch("http://api.puter.localhost:4100/drivers/call", {
"headers": {
"Content-Type": "application/json",
"Authorization": `Bearer ${puter.authToken}`,
},
"body": JSON.stringify({
interface: 'test-image',
method: 'get_image',
args: {
source_type: 'string:url:web'
}
}),
"method": "POST",
})).blob();
const endpoint = 'http://api.puter.localhost:4100/drivers/call';
const body = {
object: {
interface: 'test-image',
method: 'echo_image',
['args.source']: {
$: 'file',
size: blob.size,
type: blob.type,
},
},
file: [
blob,
]
};
const formData = new FormData();
for ( const k in body ) {
console.log('k', k);
const append = v => {
if ( v instanceof Blob ) {
formData.append(k, v, 'filename');
} else {
formData.append(k, JSON.stringify(v));
}
};
if ( Array.isArray(body[k]) ) {
for ( const v of body[k] ) append(v);
} else {
append(body[k]);
}
}
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Authorization': `Bearer ${puter.authToken}` },
body: formData
});
const echo_blob = await response.blob();
const echo_url = URL.createObjectURL(echo_blob);
return echo_url;
})();
```
================================================
FILE: src/backend/src/modules/web/APIErrorService.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const BaseService = require('../../services/BaseService');
/**
* @typedef {Object} ErrorSpec
* @property {string} code - The error code
* @property {string} status - HTTP status code
* @property {function} message - A function that generates an error message
*/
/**
* The APIErrorService class provides a mechanism for registering and managing
* error codes and messages which may be sent to clients.
*
* This allows for a single source-of-truth for error codes and messages that
* are used by multiple services.
*/
class APIErrorService extends BaseService {
_construct () {
this.codes = {
...this.constructor.codes,
};
}
// Hardcoded error codes from before this service was created
static codes = APIError.codes;
/**
* Registers API error codes.
*
* @param {Object.} codes - A map of error codes to error specifications
*/
register (codes) {
for ( const code in codes ) {
this.codes[code] = codes[code];
}
}
create (code, fields) {
const error_spec = this.codes[code];
if ( ! error_spec ) {
return new APIError(500, 'Missing error message.', null, {
code,
});
}
return new APIError(error_spec.status, error_spec.message, null, {
...fields,
code,
});
}
}
module.exports = APIErrorService;
================================================
FILE: src/backend/src/modules/web/README.md
================================================
# WebModule
This module initializes a pre-configured web server and socket.io server.
The main service, WebServerService, emits 'install.routes' and provides
the server instance to the callback.
## Services
### SocketioService
SocketioService provides a service for sending messages to clients.
socket.io is used behind the scenes. This service provides a simpler
interface for sending messages to rooms or socket ids.
#### Listeners
##### `install.socketio`
Initializes socket.io
###### Parameters
- **server:** The server to attach socket.io to.
### WebServerService
This class, WebServerService, is responsible for starting and managing the Puter web server.
It initializes the Express app, sets up middlewares, routes, and handles authentication and web sockets.
It also validates the host header and IP addresses to prevent security vulnerabilities.
#### Listeners
##### `boot.consolidation`
This method initializes the backend web server for Puter. It sets up the Express app, configures middleware, and starts the HTTP server.
##### `boot.activation`
Starts the web server and listens for incoming connections.
This method sets up the Express app, sets up middleware, and starts the server on the specified port.
It also sets up the Socket.io server for real-time communication.
##### `start.webserver`
This method starts the web server by listening on the specified port. It tries multiple ports if the first one is in use.
If the `config.http_port` is set to 'auto', it will try to find an available port in a range of 4100 to 4299.
Once the server is up and running, it emits the 'start.webserver' and 'ready.webserver' events.
If the `config.env` is set to 'dev' and `config.no_browser_launch` is false, it will open the Puter URL in the default browser.
## Notes
### Outside Imports
This module has external relative imports. When these are
removed it may become possible to move this module to an
extension.
**Imports:**
- `../../services/BaseService` (use.BaseService)
- `../../util/context.js`
- `../../services/BaseService.js`
- `../../config.js`
- `../../middleware/auth.js`
- `../../util/strutil.js`
- `../../helpers.js`
================================================
FILE: src/backend/src/modules/web/SocketioService.js
================================================
/*
* 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 .
*/
const BaseService = require('../../services/BaseService');
const socketio = require('socket.io');
const { createAdapter } = require('@socket.io/redis-streams-adapter');
const { redisClient } = require('../../clients/redis/redisSingleton');
/**
* SocketioService provides a service for sending messages to clients.
* socket.io is used behind the scenes. This service provides a simpler
* interface for sending messages to rooms or socket ids.
*/
class SocketioService extends BaseService {
/**
* Initializes socket.io
*
* @evtparam server The server to attach socket.io to.
*/
'__on_install.socketio' (_, { server }) {
/**
* @type {import('socket.io').Server}
*/
const socketioOptions = {
cors: {
origin: (origin, callback) => {
callback(null, origin);
},
credentials: true,
},
adapter: createAdapter(redisClient),
};
this.io = socketio(server, socketioOptions);
}
/**
* Sends a message to specified socket(s) or room(s)
*
* @param {Array|Object} socket_specifiers - Single or array of objects specifying target sockets/rooms
* @param {string} key - The event key/name to emit
* @param {*} data - The data payload to send
* @returns {Promise}
*/
async send (socket_specifiers, key, data) {
if ( ! Array.isArray(socket_specifiers) ) {
socket_specifiers = [socket_specifiers];
}
for ( const socket_specifier of socket_specifiers ) {
if ( socket_specifier.room ) {
this.io.to(socket_specifier.room).emit(key, data);
} else if ( socket_specifier.socket ) {
this.io.to(socket_specifier.socket).emit(key, data);
}
}
}
/**
* Checks if the specified socket or room exists
*
* @param {Object} socket_specifier - The socket specifier object
* @returns {boolean} True if the socket exists, false otherwise
*/
has (socket_specifier) {
if ( socket_specifier.room ) {
const room = this.io?.sockets.adapter.rooms.get(socket_specifier.room);
return (!!room) && room.size > 0;
}
if ( socket_specifier.socket ) {
return this.io?.sockets.sockets.has(socket_specifier.socket);
}
}
}
module.exports = SocketioService;
================================================
FILE: src/backend/src/modules/web/WebModule.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { RuntimeModule } = require('../../extension/RuntimeModule.js');
/**
* This module initializes a pre-configured web server and socket.io server.
* The main service, WebServerService, emits 'install.routes' and provides
* the server instance to the callback.
*/
class WebModule extends AdvancedBase {
async install (context) {
// === LIBS === //
const useapi = context.get('useapi');
useapi.def('web', require('./lib/__lib__.js'), { assign: true });
// Prevent extensions from loading incompatible versions of express
useapi.def('web.express', require('express'));
// Extension compatibility
const runtimeModule = new RuntimeModule({ name: 'web' });
context.get('runtime-modules').register(runtimeModule);
runtimeModule.exports = useapi.use('web');
// === SERVICES === //
const services = context.get('services');
const SocketioService = require('./SocketioService');
services.registerService('socketio', SocketioService);
const WebServerService = require('./WebServerService');
services.registerService('web-server', WebServerService);
const APIErrorService = require('./APIErrorService');
services.registerService('api-error', APIErrorService);
}
}
module.exports = {
WebModule,
};
================================================
FILE: src/backend/src/modules/web/WebServerService.d.ts
================================================
import { Server } from 'http';
import BaseService from '../../services/BaseService';
/**
* WebServerService is responsible for starting and managing the Puter web server.
*/
export class WebServerService extends BaseService {
/**
* Allow requests with undefined Origin header for a specific route.
* @param route The route (string or RegExp) to allow.
*/
allow_undefined_origin (route: string | RegExp): void;
/**
* Returns the underlying HTTP server instance.
*/
get_server (): Server;
}
export = WebServerService;
================================================
FILE: src/backend/src/modules/web/WebServerService.js
================================================
/*
* 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 .
*/
const express = require('express');
const eggspress = require('./lib/eggspress.js');
const { Context, ContextExpressMiddleware } = require('../../util/context.js');
const BaseService = require('../../services/BaseService.js');
const config = require('../../config.js');
var http = require('http');
const auth = require('../../middleware/auth.js');
const measure = require('../../middleware/measure.js');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
const relative_require = require;
const normalizeHostDomain = (domain) => {
if ( typeof domain !== 'string' ) return null;
const normalizedDomain = domain.trim().toLowerCase().replace(/^\./, '');
if ( ! normalizedDomain ) return null;
return normalizedDomain.split(':')[0];
};
const hostMatchesDomain = (hostname, domain) => {
const normalizedHost = normalizeHostDomain(hostname);
const normalizedDomain = normalizeHostDomain(domain);
if ( !normalizedHost || !normalizedDomain ) return false;
return normalizedHost === normalizedDomain ||
normalizedHost.endsWith(`.${normalizedDomain}`);
};
/**
* This class, WebServerService, is responsible for starting and managing the Puter web server.
* It initializes the Express app, sets up middlewares, routes, and handles authentication and web sockets.
* It also validates the host header and IP addresses to prevent security vulnerabilities.
*/
class WebServerService extends BaseService {
static CONCERN = 'web';
static MODULES = {
https: require('https'),
http: require('http'),
fs: require('fs'),
express: require('express'),
helmet: require('helmet'),
cookieParser: require('cookie-parser'),
compression: require('compression'),
'on-finished': require('on-finished'),
morgan: require('morgan'),
};
allowedRoutesWithUndefinedOrigins = [];
allow_undefined_origin (route) {
this.allowedRoutesWithUndefinedOrigins.push(route);
}
/**
* This method initializes the backend web server for Puter. It sets up the Express app, configures middleware, and starts the HTTP server.
*
* @param {Express} app - The Express app instance to configure.
* @returns {void}
* @private
*/
// comment above line 44 in WebServerService.js
async '__on_boot.consolidation' () {
const app = this.app;
const services = this.services;
await services.emit('install.middlewares.early', { app });
await services.emit('install.middlewares.context-aware', { app });
this.install_post_middlewares_({ app });
await services.emit('install.routes', {
app,
router_webhooks: this.router_webhooks,
});
await services.emit('install.routes-gui', { app });
// Register after other services registers theirs: Options for all requests (for CORS)
app.options('/*', (_req, res) => {
return res.sendStatus(200);
});
// Catch-all 404 for unmatched routes (e.g. api subdomain with unknown path)
// There seem to be some cases (ex: other subdomains) where this doesn't work
// as intended still, but this is an improvement over the previous behavior.
app.use((req, res) => {
res.status(404).send('Not Found');
});
this.log.debug('web server setup done');
}
install_post_middlewares_ ({ app }) {
app.use(async (req, res, next) => {
const svc_event = this.services.get('event');
const event = {
req,
res,
end_: false,
end () {
this.end_ = true;
},
};
await svc_event.emit('request.will-be-handled', event);
if ( ! event.end_ ) next();
});
}
/**
* Starts the web server and listens for incoming connections.
* This method sets up the Express app, sets up middleware, and starts the server on the specified port.
* It also sets up the Socket.io server for real-time communication.
*
* @returns {Promise} A promise that resolves once the server is started.
*/
async '__on_boot.activation' () {
const services = this.services;
await services.emit('start.webserver');
await services.emit('ready.webserver');
console.log('in case you care, ready.webserver hooks are done');
}
/**
* This method starts the web server by listening on the specified port. It tries multiple ports if the first one is in use.
* If the `config.http_port` is set to 'auto', it will try to find an available port in a range of 4100 to 4299.
* Once the server is up and running, it emits the 'start.webserver' and 'ready.webserver' events.
* If the `config.env` is set to 'dev' and `config.no_browser_launch` is false, it will open the Puter URL in the default browser.
*
* @return {Promise} A promise that resolves when the server is up and running.
*/
async '__on_start.webserver' () {
// error handling middleware goes last, as per the
// expressjs documentation:
// https://expressjs.com/en/guide/error-handling.html
this.app.use(require('./lib/api_error_handler.js'));
const { jwt_auth } = require('../../helpers.js');
config.http_port = process.env.PORT ?? config.http_port;
globalThis.deployment_type =
config.http_port === 5101 ? 'green' :
config.http_port === 5102 ? 'blue' :
'not production';
let server;
const auto_port = config.http_port === 'auto';
let ports_to_try = auto_port ? (() => {
const ports = [];
for ( let i = 0 ; i < 20 ; i++ ) {
ports.push(4100 + i);
}
return ports;
})() : [Number.parseInt(config.http_port)];
for ( let i = 0 ; i < ports_to_try.length ; i++ ) {
const port = ports_to_try[i];
const is_last_port = i === ports_to_try.length - 1;
if ( auto_port ) this.log.debug(`trying port: ${ port}`);
try {
server = http.createServer(this.app).listen(port);
server.timeout = 1000 * 60 * 60 * 2; // 2 hours
let should_continue = false;
await new Promise((rslv, rjct) => {
server.on('error', e => {
if ( e.code === 'EADDRINUSE' ) {
if ( !is_last_port && e.code === 'EADDRINUSE' ) {
this.log.info(`port in use: ${ port}`);
should_continue = true;
}
rslv();
} else {
rjct(e);
}
});
/**
* Starts the web server.
*
* This method is responsible for creating the HTTP server, setting up middleware, and starting the server on the specified port. If the specified port is "auto", it will attempt to find an available port within a range.
*
* @returns {Promise}
*/
// Add this comment above line 110
// (line 110 of the provided code)
server.on('listening', () => {
rslv();
});
});
if ( should_continue ) continue;
} catch (e) {
if ( !is_last_port && e.code === 'EADDRINUSE' ) {
this.log.info(`port in use:${ port}`);
continue;
}
throw e;
}
config.http_port = port;
break;
}
ports_to_try = null; // GC
const url = config.origin;
const args = yargs(hideBin(process.argv)).argv;
if ( args['server'] ) {
(async () => {
(await import('./../../../../../tools/auth_gui.js')).default(args['puter-backend']);
})();
config.no_browser_launch = true;
}
// Open the browser to the URL of Puter
// (if we are in development mode only)
if ( config.env === 'dev' && !config.no_browser_launch ) {
try {
const openModule = await import('open');
openModule.default(url);
} catch (e) {
console.log('Error opening browser', e);
}
}
const link = `\x1B[34;1m${url}\x1B[0m`;
const lines = [
`Puter is now live at: ${link}`,
`listening on port: ${config.http_port}`,
];
const realConsole = globalThis.original_console_object ?? console;
lines.forEach(line => realConsole.log(line));
realConsole.log('\n************************************************************');
realConsole.log(`* Puter is now live at: ${url}`);
realConsole.log('************************************************************');
server.timeout = 1000 * 60 * 60 * 2; // 2 hours
server.requestTimeout = 1000 * 60 * 60 * 2; // 2 hours
server.headersTimeout = 1000 * 60 * 60 * 2; // 2 hours
// server.keepAliveTimeout = 1000 * 60 * 60 * 2; // 2 hours
// Socket.io server instance
// const socketio = require('../../socketio.js').init(server);
// TODO: ^ Replace above line with the following code:
await this.services.emit('install.socketio', { server });
const socketio = this.services.get('socketio').io;
const authService = this.services.get('auth');
// Socket.io middleware for authentication
socketio.use(async (socket, next) => {
const authToken = socket.handshake?.auth?.auth_token;
if ( ! authToken ) {
next(new Error('socket auth token missing'));
return;
}
try {
const authRes = await jwt_auth(socket, authService);
// successful auth
socket.actor = authRes.actor;
socket.user = authRes.user;
socket.token = authRes.token;
// join user room
socket.join(socket.user.id);
// setTimeout 0 is needed because we need to send
// the notifications after this handler is done
// setTimeout(() => {
// }, 1000);
next();
} catch ( error ) {
console.warn('socket auth err', error);
const authError = error instanceof Error
? error
: new Error('socket auth failed');
next(authError);
}
});
const context = Context.get();
socketio.on('connection', (socket) => {
socket.on('disconnect', () => {
});
socket.on('trash.is_empty', (msg) => {
socket.broadcast.to(socket.user.id).emit('trash.is_empty', msg);
});
const svc_event = this.services.get('event');
svc_event.emit('web.socket.connected', {
socket,
user: socket.user,
});
socket.on('puter_is_actually_open', async (_msg) => {
await context.sub({
actor: socket.actor,
}).arun(async () => {
await svc_event.emit('web.socket.user-connected', {
socket,
user: socket.user,
});
});
});
});
this.server_ = server;
await this.services.emit('install.websockets');
}
/**
* Starts the Puter web server and sets up routes, middleware, and error handling.
*
* @param {object} services - An object containing all services available to the web server.
* @returns {Promise} A promise that resolves when the web server is fully started.
*/
get_server () {
return this.server_;
}
/**
* Handles starting and managing the Puter web server.
*
* @param {Object} services - An object containing all services.
*/
async _init () {
const app = express();
this.app = app;
app.set('services', this.services);
this.middlewares = { auth };
const require = this.require;
const config = this.global_config;
new ContextExpressMiddleware({
parent: globalThis.root_context.sub({
puter_environment: Context.create({
env: config.env,
version: relative_require('../../../package.json').version,
}),
}, 'mw'),
}).install(app);
app.use(async (req, res, next) => {
req.services = this.services;
next();
});
// When the user visits the main origin (not api/dav subdomain) with ?auth_token=
// (e.g. QR login), set the HTTP-only session cookie so user-protected endpoints work.
app.use(async (req, res, next) => {
const has_subdomain = req.hostname.slice(0, -1 * (config.domain.length + 1)) !== '';
if ( has_subdomain ) return next();
const token = req.query?.auth_token;
if ( !token || typeof token !== 'string' ) return next();
try {
const svc_auth = req.services.get('auth');
const cleanToken = token.replace('Bearer ', '').trim();
const actor = await svc_auth.authenticate_from_token(cleanToken);
const session_token = svc_auth.create_session_token_for_session(
actor.type.user,
actor.type.session,
);
res.cookie(config.cookie_name, session_token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
} catch ( e ) {
console.log('query auth token (QR Code login probably) failed');
console.error(e);
}
next();
});
// Measure data transfer amounts
app.use(measure());
// Instrument logging to use our log service
{
// Switch log function at config time; info log is configurable
const logfn = (config.logging ?? []).includes('http')
? (log, { message, fields }) => {
log.info(message);
log.debug(message, fields);
}
: (log, { message, fields }) => {
log.debug(message, fields);
};
const morgan = require('morgan');
const stream = {
write: (message) => {
const [method, url, status, responseTime] = message.split(' ');
const fields = {
method,
url,
status: parseInt(status, 10),
responseTime: parseFloat(responseTime),
};
if ( url.includes('android-icon') ) return;
// remove `puter.auth.*` query params
const safe_url = (u => {
// We need to prepend an arbitrary domain to the URL
const url = new URL(`https://example.com${ u}`);
const search = url.searchParams;
for ( const key of search.keys() ) {
if ( key.startsWith('puter.auth.') ) search.delete(key);
}
return `${url.pathname }?${ search.toString()}`;
})(fields.url);
fields.url = safe_url;
// re-write message
message = [
fields.method, fields.url,
fields.status, fields.responseTime,
].join(' ');
const log = this.services.get('log-service').create('morgan');
try {
this.context.arun(() => {
logfn(log, { message, fields });
});
} catch (e) {
console.log('failed to log this message properly:', message, fields);
console.error(e);
}
},
};
app.use(morgan(':method :url :status :response-time', { stream }));
}
/**
* Initialize the web server, start it, and handle any related logic.
*
* This method is responsible for creating the server and listening on the
* appropriate port. It also sets up middleware, routes, and other necessary
* configurations.
*
* @returns {Promise} A promise that resolves once the server is up and running.
*/
app.use((() => {
// const router = express.Router();
// router.get('/wut', express.json(), (req, res, next) => {
// return res.status(500).send('Internal Error');
// });
// return router;
return eggspress('/wut', {
allowedMethods: ['GET'],
}, async (req, res, _next) => {
// throw new Error('throwy error');
return res.status(200).send('test endpoint');
});
})());
(() => {
const onFinished = require('on-finished');
app.use((req, res, next) => {
onFinished(res, () => {
if ( res.statusCode !== 500 ) return;
if ( req.__error_handled ) return;
const alarm = this.services.get('alarm');
alarm.create('responded-500', 'server sent a 500 response', {
error: req.__error_source,
url: req.url,
method: req.method,
body: req.body,
headers: req.headers,
});
});
next();
});
})();
app.use(async function (req, res, next) {
// Express does not document that this can be undefined.
// The browser likely doesn't follow the HTTP/1.1 spec
// (bot client?) and express is handling this badly by
// not setting the header at all. (that's my theory)
if ( req.hostname === undefined ) {
res.status(400).send(
'Please verify your browser is up-to-date.',
);
return;
}
return next();
});
// Validate host header against allowed domains to prevent host header injection
// https://www.owasp.org/index.php/Host_Header_Injection
app.use((req, res, next) => {
const allowedDomains = new Set();
const pushAllowedDomain = (domain) => {
const normalizedDomain = normalizeHostDomain(domain);
if ( normalizedDomain ) {
allowedDomains.add(normalizedDomain);
}
};
const staticHostingDomain = normalizeHostDomain(config.static_hosting_domain);
pushAllowedDomain(config.domain);
pushAllowedDomain(staticHostingDomain);
pushAllowedDomain(config.static_hosting_domain_alt);
pushAllowedDomain(config.private_app_hosting_domain);
pushAllowedDomain(config.private_app_hosting_domain_alt);
if ( staticHostingDomain ) {
pushAllowedDomain(`at.${staticHostingDomain}`);
}
if ( config.allow_nipio_domains ) {
pushAllowedDomain('nip.io');
}
// Retrieve the Host header and ensure it's in a valid format
const hostHeader = req.headers.host;
if ( !config.allow_no_host_header && !hostHeader ) {
return res.status(400).send('Missing Host header.');
}
if ( config.allow_all_host_values ) {
next();
return;
}
// Parse the Host header to isolate the hostname (strip out port if present)
const hostName = hostHeader.split(':')[0].trim().toLowerCase();
// Check if the hostname matches any of the allowed domains or is a subdomain of an allowed domain
// Exception: allow /healthcheck endpoint on the root domain
if (
req.path === '/healthcheck'
) {
next();
return;
}
if ( [...allowedDomains].some(allowedDomain => hostMatchesDomain(hostName, allowedDomain)) ) {
next(); // Proceed if the host is valid
return;
} else {
if ( ! config.custom_domains_enabled ) {
res.status(400).send('Invalid Host header.');
return;
}
req.is_custom_domain = true;
next();
return;
}
});
// Validate IP with any IP checkers
app.use(async (req, res, next) => {
const svc_event = this.services.get('event');
const event = {
allow: true,
ip: req.headers?.['x-forwarded-for'] ||
req.connection?.remoteAddress,
};
if ( ! this.config.disable_ip_validate_event ) {
await svc_event.emit('ip.validate', event);
}
// rules that don't apply to notification endpoints
const undefined_origin_allowed = config.undefined_origin_allowed || this.allowedRoutesWithUndefinedOrigins.some(rule => {
if ( typeof rule === 'string' ) return rule === req.path;
return rule.test(req.path);
});
if ( ! undefined_origin_allowed ) {
// check if no origin
if ( req.method === 'POST' && req.headers.origin === undefined ) {
event.allow = false;
}
}
if ( ! event.allow ) {
return res.status(403).send('Forbidden');
}
next();
});
// Web hooks need a router that occurs before JSON parse middleware
// so that signatures of the raw JSON can be verified
this.router_webhooks = express.Router();
app.use(this.router_webhooks);
app.use((req, res, next) => {
if ( req.get('x-amz-sns-message-type') ) {
req.headers['content-type'] = 'application/json';
}
next();
});
const rawBodyBuffer = (req, res, buf, encoding) => {
req.rawBody = buf.toString(encoding || 'utf8');
};
app.use(express.json({ limit: '50mb', verify: rawBodyBuffer }));
app.use((req, res, next) => {
if ( req.headers['content-type']?.startsWith('application/json')
&& req.body
&& Buffer.isBuffer(req.body)
) {
try {
req.rawBody = req.body;
req.body = JSON.parse(req.body.toString('utf8'));
} catch {
return res.status(400).send({
error: {
message: 'Invalid JSON body',
},
});
}
}
next();
});
const cookieParser = require('cookie-parser');
app.use(cookieParser({ limit: '50mb' }));
// gzip compression for all requests
const compression = require('compression');
app.use(compression());
// Helmet and other security
const helmet = require('helmet');
app.use(helmet.noSniff());
app.use(helmet.hsts());
app.use(helmet.ieNoOpen());
app.use(helmet.permittedCrossDomainPolicies());
app.use(helmet.xssFilter());
// app.use(helmet.referrerPolicy());
app.disable('x-powered-by');
// remove object and array query parameters
app.use(function (req, res, next) {
for ( let k in req.query ) {
if ( req.query[k] === undefined || req.query[k] === null ) {
continue;
}
const allowed_types = ['string', 'number', 'boolean'];
if ( ! allowed_types.includes(typeof req.query[k]) ) {
req.query[k] = undefined;
}
}
next();
});
const uaParser = require('ua-parser-js');
app.use(function (req, res, next) {
const ua_header = req.headers['user-agent'];
const ua = uaParser(ua_header);
req.ua = ua;
next();
});
app.use(function (req, res, next) {
req.co_isolation_enabled =
['Chrome', 'Edge'].includes(req.ua.browser.name)
&& (Number(req.ua.browser.major) >= 110);
next();
});
app.use(function (req, res, next) {
const origin = req.headers.origin;
const subdomain = req.subdomains[req.subdomains.length - 1];
const isApiOrDavRequest =
config.experimental_no_subdomain ||
subdomain === 'api' ||
subdomain === 'dav';
const isCrossOriginAuthRoute =
req.path === '/signup' ||
req.path === '/login' ||
req.path.startsWith('/extensions/') ||
req.path.startsWith('/auth/oidc');
const is_site =
hostMatchesDomain(req.hostname, config.static_hosting_domain) ||
hostMatchesDomain(req.hostname, config.static_hosting_domain_alt) ||
hostMatchesDomain(req.hostname, config.private_app_hosting_domain) ||
hostMatchesDomain(req.hostname, config.private_app_hosting_domain_alt);
req.hostname === 'docs.puter.com'
;
const is_popup = !!req.query.embedded_in_popup;
const is_parent_co = !!req.query.cross_origin_isolated;
const is_app = !!req.query['puter.app_instance_id'];
const co_isolation_okay =
(!is_popup || is_parent_co) &&
(is_app || !is_site) &&
req.co_isolation_enabled
;
if ( isCrossOriginAuthRoute || isApiOrDavRequest ) {
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
if ( origin ) {
res.vary('Origin');
}
}
// Allow browser credentials on API/DAV cross-origin requests.
if ( isApiOrDavRequest && origin ) {
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
// Request methods to allow
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK');
const allowed_headers = [
'Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization', 'sentry-trace', 'baggage',
'Depth', 'Destination', 'Overwrite', 'If', 'Lock-Token', 'DAV', 'stripe-signature',
];
// Request headers to allow
res.header('Access-Control-Allow-Headers', allowed_headers.join(', '));
// Needed for SharedArrayBuffer
// NOTE: This is put behind a configuration flag because we
// need some experimentation to ensure the interface
// between apps and Puter doesn't break.
if ( config.cross_origin_isolation && co_isolation_okay ) {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
}
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
// Pass to next layer of middleware
// disable iframes on the main domain
if ( req.hostname === config.domain ) {
// disable iframes
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
}
next();
});
}
}
module.exports = WebServerService;
================================================
FILE: src/backend/src/modules/web/lib/__lib__.js
================================================
/*
* 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 .
*/
module.exports = {
eggspress: require('./eggspress'),
api_error_handler: require('./api_error_handler'),
};
================================================
FILE: src/backend/src/modules/web/lib/api_error_handler.js
================================================
/*
* 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 .
*/
const APIError = require('../../../api/APIError.js');
/**
* api_error_handler() is an express error handler for API errors.
* It adheres to the express error handler signature and should be
* used as the last middleware in an express app.
*
* Since Express 5 is not yet released, this function is used by
* eggspress() to handle errors instead of as a middleware.
*
* @param {*} err
* @param {*} req
* @param {*} res
* @param {*} next
* @returns
*/
module.exports = function api_error_handler (err, req, res, next) {
if ( res.headersSent ) {
console.error('error after headers were sent:', err);
return next(err);
}
// API errors might have a response to help the
// developer resolve the issue.
if ( err instanceof APIError ) {
return err.write(res);
}
if (
typeof err === 'object' &&
!(err instanceof Error) &&
err.hasOwnProperty('message')
) {
const apiError = APIError.create(400, err);
return apiError.write(res);
}
console.error('internal server error:', err);
const services = globalThis.services;
if ( services && services.has('alarm') ) {
const alarm = services.get('alarm');
alarm.create('api_error_handler', err.message, {
error: err,
url: req.url,
method: req.method,
body: req.body,
headers: req.headers,
});
}
req.__error_handled = true;
// Other errors should provide as little information
// to the client as possible for security reasons.
return res.send(500, 'Internal Server Error');
};
================================================
FILE: src/backend/src/modules/web/lib/eggspress.js
================================================
/*
* 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 .
*/
const express = require('express');
const multer = require('multer');
const multest = require('@heyputer/multest');
const api_error_handler = require('./api_error_handler.js');
const APIError = require('../../../api/APIError.js');
const { Context } = require('../../../util/context.js');
const { subdomain } = require('../../../helpers.js');
const config = require('../../../config.js');
/**
* eggspress() is a factory function for creating express routers.
*
* @param {*} route the route to the router
* @param {*} settings the settings for the router. The following
* properties are supported:
* - auth: whether or not to use the auth middleware
* - fs: whether or not to use the fs middleware
* - json: whether or not to use the json middleware
* - customArgs: custom arguments to pass to the router
* - allowedMethods: the allowed HTTP methods
* @param {*} handler the handler for the router
* @returns {express.Router} the router
*/
module.exports = function eggspress (route, settings, handler) {
const router = express.Router();
const mw = [];
const afterMW = [];
const _defaultJsonOptions = {};
if ( settings.jsonCanBeLarge ) {
_defaultJsonOptions.limit = '10mb';
}
// Subdomain should be checked before any other middleware to prevent
// unnecessary processing and re-sending headers.
if ( settings.subdomain ) {
mw.push((req, res, next) => {
if ( subdomain(req) !== settings.subdomain ) {
next('route');
return;
}
next();
});
}
// These flags enable specific middleware.
if ( settings.abuse ) mw.push(require('../../../middleware/abuse')(settings.abuse));
if ( settings.verified ) mw.push(require('../../../middleware/verified'));
// if json explicitly set false, don't use it
if ( settings.json !== false ) {
if ( settings.json ) mw.push(express.json(_defaultJsonOptions));
// A hack so plain text is parsed as JSON in methods which need to be lower latency/avoid the cors roundtrip
if ( settings.noReallyItsJson ) mw.push(express.json({ ..._defaultJsonOptions, type: '*/*' }));
mw.push(express.json({
..._defaultJsonOptions,
type: (req) => req.headers['content-type'] === 'text/plain;actually=json',
}));
}
if ( settings.auth ) mw.push(require('../../../middleware/auth'));
if ( settings.auth2 ) mw.push(require('../../../middleware/auth2'));
// The `files` setting is an array of strings. Each string is the name
// of a multipart field that contains files. `multer` is used to parse
// the multipart request and store the files in `req.files`.
if ( settings.files ) {
for ( const key of settings.files ) {
mw.push(multer().array(key));
}
}
if ( settings.multest ) {
mw.push(multest());
}
// The `multipart_jsons` setting is an array of strings. Each string
// is the name of a multipart field that contains JSON. This middleware
// parses the JSON in each field and stores the result in `req.body`.
if ( settings.multipart_jsons ) {
for ( const key of settings.multipart_jsons ) {
mw.push((req, res, next) => {
try {
if ( ! Array.isArray(req.body[key]) ) {
req.body[key] = [JSON.parse(req.body[key])];
} else {
req.body[key] = req.body[key].map(JSON.parse);
}
} catch ( _e ) {
return res.status(400).send({
error: {
message: `Invalid JSON in multipart field ${key}`,
},
});
}
next();
});
}
}
// The `alias` setting is an object. Each key is the name of a
// parameter. Each value is the name of a parameter that should
// be aliased to the key.
if ( settings.alias ) {
for ( const alias in settings.alias ) {
const target = settings.alias[alias];
mw.push((req, res, next) => {
const values = req.method === 'GET' ? req.query : req.body;
if ( values[alias] ) {
values[target] = values[alias];
}
next();
});
}
}
// The `parameters` setting is an object. Each key is the name of a
// parameter. Each value is a `Param` object. The `Param` object
// specifies how to validate the parameter.
if ( settings.parameters ) {
for ( const key in settings.parameters ) {
const param = settings.parameters[key];
mw.push(async (req, res, next) => {
if ( ! req.values ) req.values = {};
const values = req.method === 'GET' ? req.query : req.body;
const getParam = (key) => values[key];
try {
const result = await param.consolidate({ req, getParam });
req.values[key] = result;
} catch (e) {
api_error_handler(e, req, res, next);
return;
}
next();
});
}
}
// what if I wanted to pass arguments to, for example, `json`?
if ( settings.customArgs ) mw.push(settings.customArgs);
if ( settings.alarm_timeout ) {
mw.push((req, res, next) => {
setTimeout(() => {
if ( ! res.headersSent ) {
const log = req.services.get('log-service').create('eggspress:timeout');
const errors = req.services.get('error-service').create(log);
let id = Array.isArray(route) ? route[0] : route;
id = id.replace(/\//g, '_');
errors.report(id, {
source: new Error('Response timed out.'),
message: 'Response timed out.',
trace: true,
alarm: true,
});
}
}, settings.alarm_timeout);
next();
});
}
if ( settings.response_timeout ) {
mw.push((req, res, next) => {
setTimeout(() => {
if ( ! res.headersSent ) {
api_error_handler(APIError.create('response_timeout'), req, res, next);
}
}, settings.response_timeout);
next();
});
}
if ( settings.mw ) {
mw.push(...settings.mw);
}
const errorHandledHandler = async function (req, res, next) {
if ( settings.subdomain ) {
if ( subdomain(req) !== settings.subdomain ) {
return next();
}
}
if ( config.env === 'dev' && process.env.DEBUG ) {
console.log(`request url: ${req.url}, body: ${JSON.stringify(req.body)}`);
}
try {
const expected_ctx = res.locals.ctx;
const received_ctx = Context.get(undefined, { allow_fallback: true });
if ( expected_ctx != received_ctx ) {
await expected_ctx.arun(async () => {
await handler(req, res, next);
});
} else await handler(req, res, next);
} catch (e) {
if ( config.env === 'dev' ) {
if ( ! (e instanceof APIError) ) {
// Any non-APIError indicates an unhandled error (i.e. a bug) from the backend.
// We add a dedicated branch to facilitate debugging.
console.error(e);
}
}
api_error_handler(e, req, res, next);
}
};
if ( settings.allowedMethods.includes('GET') ) {
router.get(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('HEAD') ) {
router.head(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('POST') ) {
router.post(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('PUT') ) {
router.put(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('DELETE') ) {
router.delete(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('PROPFIND') ) {
router.propfind(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('PROPPATCH') ) {
router.proppatch(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('MKCOL') ) {
router.mkcol(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('COPY') ) {
router.copy(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('MOVE') ) {
router.move(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('LOCK') ) {
router.lock(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('UNLOCK') ) {
router.unlock(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('OPTIONS') ) {
router.options(route, ...mw, errorHandledHandler, ...afterMW);
}
return router;
};
================================================
FILE: src/backend/src/om/IdentifierUtil.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { WeakConstructorFeature } = require('../traits/WeakConstructorFeature');
const { Eq, And } = require('./query/query');
const { Entity } = require('./entitystorage/Entity');
class IdentifierUtil extends AdvancedBase {
static FEATURES = [
new WeakConstructorFeature(),
];
async detect_identifier (object, allow_mutation = false) {
const redundant_identifiers = this.om.redundant_identifiers ?? [];
let match_found = null;
for ( let key_set of redundant_identifiers ) {
key_set = Array.isArray(key_set) ? key_set : [key_set];
key_set.sort();
for ( let i = 0 ; i < key_set.length ; i++ ) {
const key = key_set[i];
const has_key = object instanceof Entity ?
await object.has(key) : object[key] !== undefined;
if ( ! has_key ) {
break;
}
if ( i === key_set.length - 1 ) {
match_found = key_set;
break;
}
}
}
if ( ! match_found ) return;
// Construct a query predicate based on the keys
const key_eqs = [];
for ( const key of match_found ) {
key_eqs.push(new Eq({
key,
value: object instanceof Entity ?
await object.get(key) : object[key],
}));
if ( object instanceof Entity ) {
if ( allow_mutation ) await object.del(key);
} else {
if ( allow_mutation ) delete object[key];
}
}
let predicate = new And({ children: key_eqs });
return predicate;
}
}
module.exports = {
IdentifierUtil,
};
================================================
FILE: src/backend/src/om/definitions/Mapping.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature');
const { Property } = require('./Property');
const { Entity } = require('../entitystorage/Entity');
const FSNodeContext = require('../../filesystem/FSNodeContext');
/**
* An instance of Mapping wraps every definition in ../mappings before
* it is registered in the 'om' collection in RegistryService.
* Both wrapping and registering are done by RegistrantService.
*/
class Mapping extends AdvancedBase {
static FEATURES = [
// Whenever you can override something, it's reasonable to want
// to pull the desired implementation from somewhere else to
// avoid repeating yourself. Class constructors are one of a few
// examples where this is typically not possible.
// However, javascript is magic, and we do what we want.
new WeakConstructorFeature(),
];
static create (context, data) {
const properties = {};
// NEXT
for ( const k in data.properties ) {
properties[k] = Property.create(context, k, data.properties[k]);
}
return new Mapping({
...data,
properties,
sql: data.sql,
});
}
async get_client_safe (data) {
const client_safe = {};
for ( const k in this.properties ) {
const prop = this.properties[k];
let value = data[k];
if ( prop.descriptor.protected ) {
continue;
}
if ( value === undefined ) {
continue;
}
let sanitized = false;
if ( value instanceof Entity ) {
value = await value.get_client_safe();
sanitized = true;
}
if ( value instanceof FSNodeContext ) {
if ( ! await value.exists() ) {
value = undefined;
continue;
}
value = await value.getSafeEntry();
sanitized = true;
}
// This is for reference properties to remove sensitive
// information in case a decorator added the real object.
if (
( !sanitized ) &&
typeof value === 'object' && value !== null &&
prop.descriptor.permissible_subproperties
) {
const old_value = value;
value = {};
for ( const subprop_name of prop.descriptor.permissible_subproperties ) {
if ( ! old_value.hasOwnProperty(subprop_name) ) {
continue;
}
value[subprop_name] = old_value[subprop_name];
}
}
// client_safe[k] = await prop.typ.get_client_safe(value);
client_safe[k] = value;
}
return client_safe;
}
}
module.exports = {
Mapping,
};
================================================
FILE: src/backend/src/om/definitions/PropType.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature');
class PropType extends AdvancedBase {
static FEATURES = [
new WeakConstructorFeature(),
];
static create (context, data, k) {
const chains = {};
const super_type = data.from && (() => {
const registry = context.get('registry');
const types = registry.get('om:proptype');
const super_type = types.get(data.from);
if ( ! super_type ) {
throw new Error(`Failed to find super type "${data.from}"`);
}
return super_type;
})();
data = { ...data };
delete data.from;
if ( super_type ) {
super_type.populate_subtype_(chains);
}
for ( const k in data ) {
if ( ! Object.prototype.hasOwnProperty.call(chains, k) ) {
chains[k] = [];
}
chains[k].push(data[k]);
}
return new PropType({
chains, name: k,
});
}
populate_subtype_ (chains) {
for ( const k in this.chains ) {
if ( ! Object.prototype.hasOwnProperty.call(chains, k) ) {
chains[k] = [];
}
chains[k].push(...this.chains[k]);
}
}
async adapt (value, extra) {
const adapters = this.chains.adapt
? [...this.chains.adapt].reverse()
: [];
for ( const adapter of adapters ) {
value = await adapter(value, extra);
}
return value;
}
async sql_dereference (value, extra) {
const sql_dereferences = this.chains.sql_dereference || [];
for ( const sql_dereference of sql_dereferences ) {
value = await sql_dereference(value, extra);
}
return value;
}
async sql_reference (value, extra) {
const sql_references = this.chains.sql_reference || [];
for ( const sql_reference of sql_references ) {
value = await sql_reference(value, extra);
}
return value;
}
async validate (value, extra) {
const validators = this.chains.validate || [];
for ( const validator of validators ) {
const result = await validator(value, extra);
if ( result !== true && result !== undefined ) {
return result;
}
}
return true;
}
async factory (extra) {
const factories = (
this.chains.factory && [...this.chains.factory].reverse()
) || [];
if ( process.env.DEBUG ) {
console.log('FACTORIES', factories);
}
for ( const factory of factories ) {
const result = await factory(extra);
if ( result !== undefined ) {
return result;
}
}
return undefined;
}
async is_set (value) {
const is_setters = this.chains.is_set || [];
for ( const is_setter of is_setters ) {
const result = await is_setter(value);
if ( ! result ) {
return false;
}
}
return true;
}
}
module.exports = {
PropType,
};
================================================
FILE: src/backend/src/om/definitions/PropType.test.js
================================================
import { describe, expect, it } from 'vitest';
const { PropType } = require('./PropType');
describe('PropType adapt chain ordering', () => {
it('runs subtype adapters before supertype adapters on every call', async () => {
const callOrder = [];
const typ = new PropType({
name: 'test',
chains: {
adapt: [
value => {
callOrder.push('super');
if ( typeof value !== 'string' ) {
throw new Error('expected string');
}
return value;
},
value => {
callOrder.push('sub');
if ( value && typeof value === 'object' && typeof value.url === 'string' ) {
return value.url;
}
return value;
},
],
},
});
await expect(typ.adapt({ url: 'https://example.com/icon-a.png' }))
.resolves.toBe('https://example.com/icon-a.png');
await expect(typ.adapt({ url: 'https://example.com/icon-b.png' }))
.resolves.toBe('https://example.com/icon-b.png');
expect(callOrder).toEqual(['sub', 'super', 'sub', 'super']);
});
});
================================================
FILE: src/backend/src/om/definitions/Property.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature');
class Property extends AdvancedBase {
static FEATURES = [
new WeakConstructorFeature(),
];
static create (context, name, descriptor) {
// Adapt descriptor
if ( typeof descriptor === 'string' ) {
descriptor = { type: descriptor };
}
const registry = context.get('registry');
const types = registry.get('om:proptype');
const typ = types.get(descriptor['type']);
if ( ! typ ) {
throw new Error(`Failed to find type "${descriptor['type']}"`);
}
// NEXT
return new Property({ name, descriptor, typ });
}
constructor (...a) {
super(...a);
}
async adapt (value) {
const { name, descriptor } = this;
try {
value = await this.typ.adapt(value, { name, descriptor });
if ( descriptor.adapt && typeof descriptor.adapt === 'function' ) {
value = await descriptor.adapt(value, { name, descriptor });
}
} catch ( e ) {
throw new Error(`Failed to adapt ${name} to ${descriptor.type}: ${e.message}`);
}
return value;
}
async sql_dereference (value) {
const { name, descriptor } = this;
return await this.typ.sql_dereference(value, { name, descriptor });
}
async sql_reference (value) {
const { name, descriptor } = this;
return await this.typ.sql_reference(value, { name, descriptor });
}
async validate (value) {
const { name, descriptor } = this;
if ( this.descriptor.validate ) {
let result = await this.descriptor.validate(value);
if ( result && result !== true ) return result;
}
return await this.typ.validate(value, { name, descriptor });
}
async factory () {
const { name, descriptor } = this;
if ( this.descriptor.factory ) {
let value = await this.descriptor.factory();
if ( value ) return value;
}
return await this.typ.factory({ name, descriptor });
}
async is_set (value) {
return await this.typ.is_set(value);
}
}
module.exports = {
Property,
};
================================================
FILE: src/backend/src/om/docs/DESIGN.md
================================================
## Entity Storage
### Chain of events
When `create` is called on an OM/ES driver:
1. The request is handled by `src/routers/drivers/call.js`
2. DriverService's `call` method is called
3. An instance of `EntityStoreImplementation` is called
4. `EntityStoreImplementation` calls the corresponding service,
such as `es:app`, which is an instance of `EntityStoreService`
5. `EntityStoreService` calls the upstream implementation of `BaseES`
6. `BaseES` has a public method which calls the implementor method
7. The implementor method (ex: `SQLES`) handles the operation
```
/call -> DriverService
-> EntityStoreImplementation -> EntityStoreService -> BaseES
-> ...(storage decorators) -> SQLES
```
================================================
FILE: src/backend/src/om/entitystorage/AppES.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { AppRedisCacheSpace } = require('../../modules/apps/AppRedisCacheSpace.js');
const { deleteRedisKeys } = require('../../clients/redis/deleteRedisKeys.js');
const config = require('../../config');
const { app_name_exists } = require('../../helpers');
const { AppUnderUserActorType } = require('../../services/auth/Actor');
const { DB_WRITE } = require('../../services/database/consts');
const { Context } = require('../../util/context');
const { origin_from_url } = require('../../util/urlutil');
const { Eq, Like, Or, And } = require('../query/query');
const { BaseES } = require('./BaseES');
const { Entity } = require('./Entity');
const uuidv4 = require('uuid').v4;
const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias';
const APP_UID_ALIAS_REVERSE_KEY_PREFIX = 'app:canonicalUidAliasReverse';
const APP_UID_ALIAS_TTL_SECONDS = 60 * 60 * 24 * 90;
const indexUrlUniquenessExemptionCandidates = [
'https://dev-center.puter.com/coming-soon',
];
const hasIndexUrlUniquenessExemption = (candidates) => {
for ( const candidate of candidates ) {
if ( indexUrlUniquenessExemptionCandidates.find(exception => candidate.startsWith(exception)) ) {
return true;
}
}
return false;
};
const normalizeConfiguredHostedDomain = (domainValue) => {
if ( typeof domainValue !== 'string' ) return null;
const normalizedDomainValue = domainValue.trim().toLowerCase().replace(/^\./, '');
if ( ! normalizedDomainValue ) return null;
return normalizedDomainValue.split(':')[0] || null;
};
const getConfiguredHostedDomains = () => {
const hostedDomains = new Set();
for ( const configuredDomain of [
config.static_hosting_domain,
config.static_hosting_domain_alt,
config.private_app_hosting_domain,
config.private_app_hosting_domain_alt,
] ) {
const normalizedDomain = normalizeConfiguredHostedDomain(configuredDomain);
if ( normalizedDomain ) {
hostedDomains.add(normalizedDomain);
}
}
return [...hostedDomains];
};
const extractPuterHostedSubdomainFromIndexUrl = (indexUrl) => {
if ( typeof indexUrl !== 'string' || !indexUrl ) return null;
let hostname;
try {
hostname = (new URL(indexUrl)).hostname.toLowerCase();
} catch {
return null;
}
const hostedDomains = getConfiguredHostedDomains()
.sort((domainA, domainB) => domainB.length - domainA.length);
for ( const hostedDomain of hostedDomains ) {
const suffix = `.${hostedDomain}`;
if ( hostname.endsWith(suffix) ) {
const subdomain = hostname.slice(0, hostname.length - suffix.length);
return subdomain || null;
}
}
return null;
};
let privateLaunchAccessModulePromise;
const getPrivateLaunchAccessModule = async () => {
if ( ! privateLaunchAccessModulePromise ) {
privateLaunchAccessModulePromise = import('../../modules/apps/privateLaunchAccess.js');
}
return privateLaunchAccessModulePromise;
};
class AppES extends BaseES {
static METHODS = {
async _on_context_provided () {
const services = this.context.get('services');
this.db = services.get('database').get(DB_WRITE, 'apps');
},
/**
* Creates query predicates for filtering apps
* @param {string} id - Predicate identifier
* @param {...any} args - Additional arguments for predicate creation
* @returns {Promise} Query predicate object
*/
async create_predicate (id, ...args) {
if ( id === 'user-can-edit' ) {
return new Eq({
key: 'owner',
value: Context.get('user').id,
});
}
if ( id === 'name-like' ) {
return new Like({
key: 'name',
value: args[0],
});
}
},
async delete (uid, _extra) {
const svc_appInformation = this.context.get('services').get('app-information');
await svc_appInformation.delete_app(uid);
},
async read (uid) {
if ( typeof uid !== 'string' || !uid ) {
return await this.upstream.read(uid);
}
const canonicalUidAliasPromise = this.read_canonical_app_uid_alias_(uid);
const entity = await this.upstream.read(uid);
if ( entity ) {
return entity;
}
const canonicalUid = await canonicalUidAliasPromise;
if ( !canonicalUid || canonicalUid === uid ) {
return null;
}
return await this.upstream.read(canonicalUid);
},
/**
* Filters app selection based on user permissions and visibility settings
* @param {Object} options - Selection options including predicates
* @returns {Promise} Filtered selection results
*/
async select (options) {
const actor = Context.get('actor');
const user = actor.type.user;
const additional = [];
// An app is also allowed to read itself
if ( actor.type instanceof AppUnderUserActorType ) {
additional.push(new Eq({
key: 'uid',
value: actor.type.app.uid,
}));
}
options.predicate = options.predicate.and(new Or({
children: [
new Eq({
key: 'approved_for_listing',
value: 1,
}),
new Eq({
key: 'owner',
value: user.id,
}),
...additional,
],
}));
return await this.upstream.select(options);
},
/**
* Creates or updates an application with proper name handling and associations
* @param {Object} entity - Application entity to upsert
* @param {Object} extra - Additional upsert parameters
* @returns {Promise} Upsert operation results
*/
async upsert (entity, extra) {
extra = extra || {};
const actor = Context.get('actor');
const user = actor?.type?.user;
const preJoinFullEntity = extra.old_entity
? await (await extra.old_entity.clone()).apply(entity)
: entity
;
await this.ensurePuterSiteSubdomainIsOwned(preJoinFullEntity, extra, user);
await this.maybe_join_owned_hosted_index_url_app_on_create_(entity, extra, user);
const full_entity = extra.old_entity
? await (await extra.old_entity.clone()).apply(entity)
: entity
;
await this.ensureIndexUrlUnique(full_entity, extra);
if ( await app_name_exists(await entity.get('name')) ) {
const { old_entity } = extra;
const is_name_change = ( !old_entity ) ||
( await old_entity.get('name') !== await entity.get('name') );
if ( is_name_change && extra?.options?.dedupe_name ) {
const base = await entity.get('name');
let number = 1;
while ( await app_name_exists(`${base}-${number}`) ) {
number++;
}
await entity.set('name', `${base}-${number}`);
}
else if ( is_name_change ) {
// The name might be taken because it's the old name
// of this same app. If it is, the app takes it back.
const svc_oldAppName = this.context.get('services').get('old-app-name');
const name_info = await svc_oldAppName.check_app_name(await entity.get('name'));
if ( !name_info || name_info.app_uid !== await entity.get('uid') ) {
// Throw error because the name really is taken
throw APIError.create('app_name_already_in_use', null, {
name: await entity.get('name'),
});
}
// Remove the old name from the old-app-name service
await svc_oldAppName.remove_name(name_info.id);
} else {
entity.del('name');
}
}
const subdomain_id = await this.maybe_insert_subdomain_(entity);
const result = await this.upstream.upsert(entity, extra);
const { insert_id } = result;
const oldAssociations = await this.db.read(
'SELECT type FROM app_filetype_association WHERE app_id = ?',
[insert_id],
);
const normalizedOldAssociations = oldAssociations
.map(row => String(row.type ?? '').trim().toLowerCase().replace(/^\./, ''))
.filter(Boolean);
// Remove old file associations (if applicable)
if ( extra.old_entity ) {
await this.db.write(
'DELETE FROM app_filetype_association WHERE app_id = ?',
[insert_id],
);
}
// Add file associations (if applicable)
const filetype_associations = await entity.get('filetype_associations');
const normalizedNewAssociations = (filetype_associations ?? [])
.map(association => String(association).trim().toLowerCase().replace(/^\./, ''))
.filter(Boolean);
if ( (a => a && a.length > 0)(filetype_associations) ) {
const stmt =
'INSERT INTO app_filetype_association ' +
`(app_id, type) VALUES ${
normalizedNewAssociations.map(() => '(?, ?)').join(', ')}`;
const rows = normalizedNewAssociations.map(a => [insert_id, a]);
await this.db.write(stmt, rows.flat());
}
const affectedAssociationExtensions = new Set([
...normalizedOldAssociations,
...normalizedNewAssociations,
]);
if ( affectedAssociationExtensions.size ) {
await deleteRedisKeys(Array.from(affectedAssociationExtensions)
.map(ext => AppRedisCacheSpace.associationAppsKey(ext)));
}
const has_new_icon =
( !extra.old_entity ) || (
await entity.get('icon') !== await extra.old_entity.get('icon')
);
if ( has_new_icon ) {
const svc_event = this.context.get('services').get('event');
const event = {
app_uid: await entity.get('uid'),
data_url: await entity.get('icon'),
url: '',
};
await svc_event.emit('app.new-icon', event);
if ( typeof event.url === 'string' && event.url ) {
this.db.write(
'UPDATE apps SET icon = ? WHERE id = ? LIMIT 1',
[event.url, insert_id],
);
await entity.set('icon', event.url);
}
}
const has_new_name =
extra.old_entity && (
await entity.get('name') !== await extra.old_entity.get('name')
);
if ( has_new_name ) {
const svc_event = this.context.get('services').get('event');
const event = {
app_uid: await entity.get('uid'),
new_name: await entity.get('name'),
old_name: await extra.old_entity.get('name'),
};
await svc_event.emit('app.rename', event);
}
// Associate app with subdomain (if applicable)
if ( subdomain_id ) {
await this.db.write(
'UPDATE subdomains SET associated_app_id = ? WHERE id = ?',
[insert_id, subdomain_id],
);
}
if ( extra.old_entity ) {
const svc_event = this.context.get('services').get('event');
const [app] = await this.db.read(
'SELECT * FROM apps WHERE uid = ? LIMIT 1',
[await full_entity.get('uid')],
);
const old_app = {
uid: await extra.old_entity.get('uid'),
index_url: await extra.old_entity.get('index_url'),
};
await svc_event.emit('app.changed', {
app_uid: await full_entity.get('uid'),
action: 'updated',
app,
old_app,
});
}
if ( extra.joined_source_app_uid ) {
await this.write_canonical_app_uid_alias_({
oldAppUid: extra.joined_source_app_uid,
canonicalAppUid: await full_entity.get('uid'),
});
const svc_appInformation = this.context.get('services').get('app-information');
if ( svc_appInformation?.delete_app ) {
await svc_appInformation.delete_app(extra.joined_source_app_uid, undefined, {
preserveCanonicalUidAlias: true,
});
}
}
if ( typeof extra.joined_requested_name === 'string' && extra.joined_requested_name.trim() ) {
const renameResult = await this.apply_joined_requested_name_({
canonicalUid: await full_entity.get('uid'),
requestedName: extra.joined_requested_name,
});
if ( renameResult ) {
const svc_event = this.context.get('services').get('event');
await svc_event.emit('app.rename', {
app_uid: await full_entity.get('uid'),
old_name: renameResult.oldName,
new_name: renameResult.newName,
});
await full_entity.set('name', renameResult.newName);
}
}
return result;
},
async retry_predicate_rewrite ({ predicate }) {
const recurse = async (predicate) => {
if ( predicate instanceof Or ) {
return new Or({
children: await Promise.all(predicate.children.map(recurse)),
});
}
if ( predicate instanceof And ) {
return new And({
children: await Promise.all(predicate.children.map(recurse)),
});
}
if ( predicate instanceof Eq ) {
if ( predicate.key === 'name' ) {
const svc_oldAppName = this.context.get('services').get('old-app-name');
const name_info = await svc_oldAppName.check_app_name(predicate.value);
return new Eq({
key: 'uid',
value: name_info?.app_uid,
});
}
}
};
return await recurse(predicate);
},
async queueIconMigration (entity) {
if ( ! this.pending_icon_migrations_ ) {
this.pending_icon_migrations_ = new Set();
}
const migration_key = entity.private_meta?.mysql_id ?? Symbol('app-icon-migration');
if ( this.pending_icon_migrations_.has(migration_key) ) {
return;
}
this.pending_icon_migrations_.add(migration_key);
Promise.resolve().then(async () => {
const icon = await entity.get('icon');
if ( typeof icon !== 'string' || !icon.startsWith('data:') ) {
return;
}
const app_uid = await entity.get('uid');
if ( ! app_uid ) {
return;
}
const svc_event = this.context.get('services').get('event');
const event = {
app_uid,
data_url: icon,
};
await svc_event.emit('app.new-icon', event);
if ( typeof event.url !== 'string' || !event.url ) return;
await this.db.write(
'UPDATE apps SET icon = ? WHERE uid = ? LIMIT 1',
[event.url, app_uid],
);
}).catch(e => {
const svc_error = this.context.get('services').get('error-service');
svc_error.report('AppES:queue_icon_migration', { source: e });
}).finally(() => {
this.pending_icon_migrations_.delete(migration_key);
});
},
/**
* Transforms app data before reading by adding associations and handling permissions
* @param {Object} entity - App entity to transform
*/
async read_transform (entity) {
const {
getActorUserUid,
resolvePrivateLaunchAccess,
} = await getPrivateLaunchAccessModule();
const services = this.context.get('services');
const actor = Context.get('actor');
const esParams = Context.get('es_params') ?? {};
const appUid = await entity.get('uid');
const appName = await entity.get('name');
const appIndexUrl = await entity.get('index_url');
const appCreatedAt = await entity.get('created_at');
const appIsPrivate = await entity.get('is_private');
const appInformationService = services.get('app-information');
const authService = services.get('auth');
const statsPromise = appInformationService
? appInformationService.get_stats(appUid, {
period: esParams.stats_period,
grouping: esParams.stats_grouping,
created_at: appCreatedAt,
})
: Promise.resolve(undefined);
const fileAssociationsPromise = this.db.read(
'SELECT type FROM app_filetype_association WHERE app_id = ?',
[entity.private_meta.mysql_id],
);
const createdFromOriginPromise = (async () => {
if ( ! authService ) return null;
try {
const origin = origin_from_url(appIndexUrl);
const expectedUid = await authService.app_uid_from_origin(origin);
return expectedUid === appUid ? origin : null;
} catch {
// This happens when index_url is not a valid URL.
return null;
}
})();
const privateAccessPromise = resolvePrivateLaunchAccess({
app: {
uid: appUid,
name: appName,
is_private: appIsPrivate,
},
services,
userUid: getActorUserUid(actor),
source: 'driverRead',
args: esParams,
});
const [
fileAssociationRows,
stats,
createdFromOrigin,
privateAccess,
] = await Promise.all([
fileAssociationsPromise,
statsPromise,
createdFromOriginPromise,
privateAccessPromise,
]);
await entity.set(
'filetype_associations',
fileAssociationRows.map(row => row.type),
);
await entity.set('stats', stats);
await entity.set('created_from_origin', createdFromOrigin);
await entity.set('privateAccess', privateAccess);
// Migrate b64 icons to the filesystem-backed icon flow without blocking reads.
this.queueIconMigration(entity);
// Check if the user is the owner
const is_owner = await (async () => {
let owner = await entity.get('owner');
// TODO: why does this happen?
if ( typeof owner === 'number' ) {
owner = { id: owner };
}
if ( ! owner ) return false;
const actor = Context.get('actor');
return actor.type.user.id === owner.id;
})();
// Remove fields that are not allowed for non-owners
if ( ! is_owner ) {
entity.del('approved_for_listing');
entity.del('approved_for_opening_items');
entity.del('approved_for_incentive_program');
}
// Replace icon if an icon size is specified
const iconSize = Context.get('es_params')?.icon_size;
if ( iconSize ) {
const svc_appIcon = this.context.get('services').get('app-icon');
try {
const iconPath = svc_appIcon.getAppIconPath({
appUid: await entity.get('uid'),
size: iconSize,
});
if ( iconPath ) {
await entity.set('icon', iconPath);
}
} catch (e) {
const svc_error = this.context.get('services').get('error-service');
svc_error.report('AppES:read_transform', { source: e });
}
}
},
/**
* Creates a subdomain entry for the app if required
* @param {Object} entity - App entity
* @returns {Promise} Subdomain ID if created
* @private
*/
async maybe_insert_subdomain_ (entity) {
// Create and update is a situation where we might create a subdomain
let subdomain_id;
if ( await entity.get('source_directory') ) {
await (await entity.get('source_directory')
).fetchEntry();
const subdomain = await entity.get('subdomain');
const user = Context.get('user');
let subdomain_res = await this.db.write(
`INSERT ${this.db.case({
mysql: 'IGNORE',
sqlite: 'OR IGNORE',
})} INTO subdomains
(subdomain, user_id, root_dir_id, uuid) VALUES
( ?, ?, ?, ?)`,
[
//subdomain
subdomain,
//user_id
user.id,
//root_dir_id
(await entity.get('source_directory')).mysql_id,
//uuid, `sd` stands for subdomain
`sd-${ uuidv4()}`,
],
);
subdomain_id = subdomain_res.insertId;
}
return subdomain_id;
},
/**
* Ensures that when an app uses a puter.site subdomain as its index_url,
* the subdomain belongs to the user creating/updating the app.
*/
async ensurePuterSiteSubdomainIsOwned (entity, extra, user) {
if ( ! user ) return;
// Only enforce when the index_url is being set or changed
const new_index_url = await entity.get('index_url');
if ( ! new_index_url ) return;
if ( extra.old_entity ) {
const old_index_url = await extra.old_entity.get('index_url');
if ( old_index_url === new_index_url ) {
return;
}
}
const subdomain = extractPuterHostedSubdomainFromIndexUrl(new_index_url);
if ( ! subdomain ) return;
const svc_puterSite = this.context.get('services').get('puter-site');
const site = await svc_puterSite.get_subdomain(subdomain, { is_custom_domain: false });
if ( !site || site.user_id !== user.id ) {
throw APIError.create('subdomain_not_owned', null, { subdomain });
}
},
is_puter_hosted_index_url_ (index_url) {
return !!extractPuterHostedSubdomainFromIndexUrl(index_url);
},
build_equivalent_index_url_candidates_ (index_url) {
if ( typeof index_url !== 'string' || !index_url.trim() ) {
return [];
}
try {
const parsedUrl = new URL(index_url);
const origin = `${parsedUrl.protocol}//${parsedUrl.host.toLowerCase()}`;
const pathname = parsedUrl.pathname || '/';
const values = new Set();
if ( pathname === '/' || pathname.toLowerCase() === '/index.html' ) {
values.add(origin);
values.add(`${origin}/`);
values.add(`${origin}/index.html`);
} else {
const normalizedPath = pathname.endsWith('/')
? pathname.slice(0, -1)
: pathname;
values.add(`${origin}${normalizedPath}`);
values.add(`${origin}${normalizedPath}/`);
}
return [...values];
} catch {
return [index_url.trim()];
}
},
async find_index_url_conflict_ ({ indexUrl, excludeMysqlId }) {
if ( ! this.is_puter_hosted_index_url_(indexUrl) ) {
return null;
}
const candidates = this.build_equivalent_index_url_candidates_(indexUrl);
if ( candidates.length === 0 ) return null;
if ( hasIndexUrlUniquenessExemption(candidates) ) return null;
const placeholders = candidates.map(() => '?').join(', ');
const parameters = [...candidates];
let query = `SELECT id, uid, owner_user_id, index_url FROM apps WHERE index_url IN (${placeholders})`;
if ( Number.isInteger(excludeMysqlId) && excludeMysqlId > 0 ) {
query += ' AND id != ?';
parameters.push(excludeMysqlId);
}
query += ' ORDER BY timestamp ASC, id ASC LIMIT 1';
const rows = await this.db.read(query, parameters);
const normalizedExcludeMysqlId = Number(excludeMysqlId);
const conflictRow = rows.find(row => {
if (
Number.isInteger(normalizedExcludeMysqlId)
&& normalizedExcludeMysqlId > 0
&& Number(row?.id) === normalizedExcludeMysqlId
) {
return false;
}
if ( typeof row?.index_url === 'string' ) {
return candidates.includes(row.index_url);
}
return true;
});
return conflictRow || null;
},
async resolve_entity_mysql_id_ (entity) {
const directMysqlId = Number(entity?.private_meta?.mysql_id);
if ( Number.isInteger(directMysqlId) && directMysqlId > 0 ) {
return directMysqlId;
}
if ( !entity || typeof entity.get !== 'function' ) {
return undefined;
}
const uid = await entity.get('uid');
if ( typeof uid !== 'string' || !uid ) {
return undefined;
}
const rows = await this.db.read(
'SELECT id FROM apps WHERE uid = ? LIMIT 1',
[uid],
);
const mysqlId = Number(rows?.[0]?.id);
if ( Number.isInteger(mysqlId) && mysqlId > 0 ) {
return mysqlId;
}
return undefined;
},
async claim_app_ownership_by_id_for_user_ ({ appId, userId }) {
if ( !Number.isInteger(appId) || appId <= 0 ) return;
if ( !Number.isInteger(userId) || userId <= 0 ) return;
await this.db.write(
'UPDATE apps SET owner_user_id = ? WHERE id = ? AND owner_user_id IS NULL',
[userId, appId],
);
},
build_canonical_app_uid_alias_key_ (oldAppUid) {
return `${APP_UID_ALIAS_KEY_PREFIX}:${oldAppUid}`;
},
build_canonical_app_uid_alias_reverse_key_ (canonicalAppUid) {
return `${APP_UID_ALIAS_REVERSE_KEY_PREFIX}:${canonicalAppUid}`;
},
normalize_canonical_alias_uid_list_ (value) {
if ( ! Array.isArray(value) ) return [];
const normalizedList = [];
const seen = new Set();
for ( const item of value ) {
if ( typeof item !== 'string' || !item ) continue;
if ( seen.has(item) ) continue;
seen.add(item);
normalizedList.push(item);
}
return normalizedList;
},
async read_canonical_app_uid_alias_ (oldAppUid) {
if ( typeof oldAppUid !== 'string' || !oldAppUid ) return null;
const services = this.context.get('services');
const kvStore = services.get('puter-kvstore');
const suService = services.get('su');
if ( !kvStore || typeof kvStore.get !== 'function' ) return null;
if ( !suService || typeof suService.sudo !== 'function' ) return null;
const key = this.build_canonical_app_uid_alias_key_(oldAppUid);
try {
const canonicalAppUid = await suService.sudo(() => kvStore.get({ key }));
if ( typeof canonicalAppUid === 'string' && canonicalAppUid ) {
return canonicalAppUid;
}
} catch {
// Alias reads are best-effort.
}
return null;
},
async write_canonical_app_uid_alias_ ({ oldAppUid, canonicalAppUid }) {
if ( typeof oldAppUid !== 'string' || !oldAppUid ) return;
if ( typeof canonicalAppUid !== 'string' || !canonicalAppUid ) return;
if ( oldAppUid === canonicalAppUid ) return;
const services = this.context.get('services');
const kvStore = services.get('puter-kvstore');
const suService = services.get('su');
if ( !kvStore || typeof kvStore.set !== 'function' ) return;
if ( !suService || typeof suService.sudo !== 'function' ) return;
const key = this.build_canonical_app_uid_alias_key_(oldAppUid);
const reverseKey = this.build_canonical_app_uid_alias_reverse_key_(canonicalAppUid);
const expireAt = Math.floor(Date.now() / 1000) + APP_UID_ALIAS_TTL_SECONDS;
try {
await suService.sudo(async () => {
const reverseValue = await kvStore.get({ key: reverseKey });
const reverseAliases = this.normalize_canonical_alias_uid_list_(reverseValue);
if ( ! reverseAliases.includes(oldAppUid) ) {
reverseAliases.push(oldAppUid);
}
await kvStore.set({
key,
value: canonicalAppUid,
expireAt,
});
await kvStore.set({
key: reverseKey,
value: reverseAliases,
expireAt,
});
});
} catch {
// Alias writes are best-effort.
}
},
async maybe_join_owned_hosted_index_url_app_on_create_ (entity, extra, user) {
if ( ! user ) return;
const new_index_url = await entity.get('index_url');
const source_entity = extra.old_entity;
const currentMysqlId = await this.resolve_entity_mysql_id_(extra.old_entity);
const conflictRow = await this.find_index_url_conflict_({
indexUrl: new_index_url,
excludeMysqlId: currentMysqlId,
});
if ( ! conflictRow ) return;
const conflictOwnerUserId = Number(conflictRow.owner_user_id);
if (
Number.isInteger(conflictOwnerUserId)
&& conflictOwnerUserId > 0
&& conflictOwnerUserId !== user.id
) {
throw APIError.create('app_index_url_already_in_use', null, {
index_url: new_index_url,
app_uid: conflictRow.uid,
});
}
if ( !Number.isInteger(conflictOwnerUserId) || conflictOwnerUserId <= 0 ) {
await this.claim_app_ownership_by_id_for_user_({
appId: conflictRow.id,
userId: user.id,
});
}
const old_entity = await this.upstream.read(conflictRow.uid);
const owner = await old_entity?.get('owner');
let ownerUserId = owner?.id ?? owner;
if ( owner instanceof Entity ) {
ownerUserId = owner.private_meta.mysql_id;
}
ownerUserId = Number(ownerUserId);
if ( !old_entity || !Number.isInteger(ownerUserId) || ownerUserId !== user.id ) {
throw APIError.create('app_index_url_already_in_use', null, {
index_url: new_index_url,
app_uid: conflictRow.uid,
});
}
if (
Number.isInteger(conflictOwnerUserId)
&& conflictOwnerUserId === user.id
&& !await this.is_origin_bootstrap_app_entity_(old_entity)
) {
// Prevent merging arbitrary same-owner apps; only allow the
// auto-created origin bootstrap app to be absorbed.
throw APIError.create('app_index_url_already_in_use', null, {
index_url: new_index_url,
app_uid: conflictRow.uid,
});
}
if ( source_entity ) {
const sourceUid = await source_entity.get('uid');
const targetUid = await old_entity.get('uid');
const requestedName = await entity.get('name');
if (
sourceUid
&& targetUid
&& sourceUid !== targetUid
&& requestedName !== undefined
) {
entity.del('name');
if ( typeof requestedName === 'string' && requestedName.trim() ) {
extra.joined_requested_name = requestedName.trim();
}
}
if ( sourceUid && targetUid && sourceUid !== targetUid ) {
extra.joined_source_app_uid = sourceUid;
}
}
await entity.set('uid', await old_entity.get('uid'));
extra.old_entity = old_entity;
},
async apply_joined_requested_name_ ({ canonicalUid, requestedName }) {
if ( typeof canonicalUid !== 'string' || !canonicalUid ) return null;
if ( typeof requestedName !== 'string' || !requestedName.trim() ) return null;
const normalizedName = requestedName.trim();
const currentRows = await this.db.read(
'SELECT name FROM apps WHERE uid = ? LIMIT 1',
[canonicalUid],
);
const currentName = currentRows?.[0]?.name;
if ( typeof currentName !== 'string' ) return null;
if ( currentName === normalizedName ) return null;
const conflictRows = await this.db.read(
'SELECT uid FROM apps WHERE name = ? AND uid != ? LIMIT 1',
[normalizedName, canonicalUid],
);
if ( conflictRows.length > 0 ) {
throw APIError.create('app_name_already_in_use', null, {
name: normalizedName,
});
}
await this.db.write(
'UPDATE apps SET name = ? WHERE uid = ? LIMIT 1',
[normalizedName, canonicalUid],
);
return {
oldName: currentName,
newName: normalizedName,
};
},
async is_origin_bootstrap_app_entity_ (entity) {
if ( ! entity ) return false;
const uid = await entity.get('uid');
if ( typeof uid !== 'string' || !uid ) return false;
if ( await entity.get('name') !== uid ) return false;
if ( await entity.get('title') !== uid ) return false;
const description = await entity.get('description');
if ( typeof description !== 'string' ) return false;
return description.startsWith('App created from origin ');
},
async ensureIndexUrlUnique (entity, extra) {
const new_index_url = await entity.get('index_url');
if ( ! new_index_url ) return;
if ( ! this.is_puter_hosted_index_url_(new_index_url) ) return;
if ( extra.old_entity ) {
const old_index_url = await extra.old_entity.get('index_url');
if ( old_index_url === new_index_url ) {
return;
}
}
const currentMysqlId = await this.resolve_entity_mysql_id_(extra.old_entity);
const conflictRow = await this.find_index_url_conflict_({
indexUrl: new_index_url,
excludeMysqlId: currentMysqlId,
});
if ( conflictRow ) {
throw APIError.create('app_index_url_already_in_use', null, {
index_url: new_index_url,
app_uid: conflictRow.uid,
});
}
},
};
}
module.exports = AppES;
================================================
FILE: src/backend/src/om/entitystorage/AppLimitedES.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { AppUnderUserActorType } = require('../../services/auth/Actor');
const { PermissionUtil } = require('../../services/auth/permissionUtils.mjs');
const { Context } = require('../../util/context');
const { Eq, Or } = require('../query/query');
const { BaseES } = require('./BaseES');
const { Entity } = require('./Entity');
class AppLimitedES extends BaseES {
// #region read operations
// Limit selection to entities owned by the app of the current actor.
async select (options) {
const actor = Context.get('actor');
app_under_user_check:
if ( actor.type instanceof AppUnderUserActorType ) {
const svc_permission = Context.get('services').get('permission');
const perm = PermissionUtil.join(this.permission_prefix, actor.type.user.uuid, 'read');
const can_read_any = await svc_permission.check(actor, perm);
if ( can_read_any ) break app_under_user_check;
if ( this.exception && typeof this.exception === 'function' ) {
this.exception = await this.exception();
}
let condition = new Eq({
key: 'app_owner',
value: actor.type.app,
});
if ( this.exception ) {
condition = new Or({
children: [
condition,
this.exception,
],
});
}
options.predicate = options.predicate.and(condition);
}
return await this.upstream.select(options);
}
// Limit read to entities owned by the app of the current actor.
async read (uid) {
const entity = await this.upstream.read(uid);
if ( ! entity ) return null;
const actor = Context.get('actor');
if ( actor.type instanceof AppUnderUserActorType ) {
if ( this.exception && typeof this.exception === 'function' ) {
this.exception = await this.exception();
}
// On the exception, we don't have to check app_owner
// (for `es:apps` this is `approved_for_listing == 1`)
if ( this.exception && await entity.check(this.exception) ) {
return entity;
}
const app = actor.type.app;
const app_owner = await entity.get('app_owner');
let app_owner_id = app_owner?.id;
if ( app_owner instanceof Entity ) {
app_owner_id = app_owner.private_meta.mysql_id;
}
if ( ( !app_owner ) || app_owner_id !== app.id ) {
return null;
}
}
return entity;
}
// #endregion
// #region write operations
// Limit edit to entities owned by the app of the current actor
async upsert (entity, extra) {
const actor = Context.get('actor');
if ( actor.type instanceof AppUnderUserActorType ) {
const { old_entity } = extra;
if ( old_entity ) {
await this._check_edit_allowed({ old_entity });
}
}
return await this.upstream.upsert(entity, extra);
}
async delete (uid, extra) {
const actor = Context.get('actor');
if ( actor.type instanceof AppUnderUserActorType ) {
const { old_entity } = extra;
await this._check_edit_allowed({ old_entity });
}
return await this.upstream.delete(uid, extra);
}
async _check_edit_allowed ({ old_entity }) {
const actor = Context.get('actor');
// Maybe the app has been granted write access to all the user's apps
// (in which case we return early)
{
const svc_permission = Context.get('services').get('permission');
const perm = PermissionUtil.join(this.permission_prefix, actor.type.user.uuid, 'write');
const can_write_any = await svc_permission.check(actor, perm);
if ( can_write_any ) return;
}
// Otherwise, verify the app owner
// (or we throw an APIError)
{
const app = actor.type.app;
const app_owner = await old_entity.get('app_owner');
let app_owner_id = app_owner?.id;
if ( app_owner instanceof Entity ) {
app_owner_id = app_owner.private_meta.mysql_id;
}
if ( ( !app_owner ) || app_owner_id !== app.id ) {
throw APIError.create('forbidden');
}
}
}
// #endregion
}
module.exports = {
AppLimitedES,
};
================================================
FILE: src/backend/src/om/entitystorage/BaseES.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature');
/**
* BaseES is a base class for Entity Store classes.
*/
class BaseES extends AdvancedBase {
static FEATURES = [
new WeakConstructorFeature(),
];
// Default implementations
static METHODS = {
async upsert (entity, extra) {
if ( ! this.upstream ) {
throw Error('Missing terminal operation');
}
return await this.upstream.upsert(entity, extra);
},
async read (uid) {
if ( ! this.upstream ) {
throw Error('Missing terminal operation');
}
return await this.upstream.read(uid);
},
async delete (uid, extra) {
if ( ! this.upstream ) {
throw Error('Missing terminal operation');
}
return await this.upstream.delete(uid, extra);
},
async select (options) {
if ( ! this.upstream ) {
throw Error('Missing terminal operation');
}
return await this.upstream.select(options);
},
async create_predicate (id, ...args) {
if ( ! this.upstream ) {
throw Error('Missing terminal operation');
}
return await this.upstream.create_predicate(id, ...args);
},
};
constructor (...a) {
super(...a);
const public_wrappers = [
'upsert', 'read', 'delete', 'select',
'read_transform',
'retry_predicate_rewrite',
];
this.impl_methods = this._get_merged_static_object('METHODS');
for ( const k in this.impl_methods ) {
// Some methods are part of the implicit EntityStorage interface.
// We won't let the implementor override these; instead we
// provide a delegating implementation where they override a
// lower-level method of the same name.
if ( public_wrappers.includes(k) ) continue;
this[k] = this.impl_methods[k];
}
}
async provide_context ( args ) {
for ( const k in args ) this[k] = args[k];
if ( this.upstream ) {
await this.upstream.provide_context(args);
}
if ( this._on_context_provided ) {
await this._on_context_provided(args);
}
}
async read (uid) {
let entity = await this.call_on_impl_('read', uid);
if ( ! entity ) {
const retry_predicate = await this.retry_predicate_rewrite(uid);
if ( retry_predicate ) {
entity = await this.call_on_impl_('read',
{ predicate: retry_predicate });
}
}
if ( ! this.impl_methods.read_transform ) return entity;
return await this.read_transform(entity);
}
async upsert (entity, extra) {
return await this.call_on_impl_('upsert', entity, extra ?? {});
}
async delete (uid, extra) {
return await this.call_on_impl_('delete', uid, extra ?? {});
}
async select (options) {
const results = await this.call_on_impl_('select', options);
if ( ! this.impl_methods.read_transform ) return results;
// Promises "solved callback hell" but like...
return await Promise.all(results.map(async entity => {
return await this.read_transform(entity);
}));
}
async retry_predicate_rewrite ({ predicate }) {
if ( ! this.impl_methods.retry_predicate_rewrite ) return;
return await this.call_on_impl_('retry_predicate_rewrite', { predicate });
}
async read_transform (entity) {
if ( ! entity ) return entity;
if ( ! this.impl_methods.read_transform ) return entity;
const maybe_entity = await this.call_on_impl_('read_transform', entity);
if ( ! maybe_entity ) return entity;
return maybe_entity;
}
call_on_impl_ (method_name, ...args) {
// const pseudo_this = { ...this };
// pseudo_this.next = this.upstream?.call_on_impl?.bind(this.upstream, method_name);
return this.impl_methods[method_name].call(this, ...args);
}
}
module.exports = {
BaseES,
};
================================================
FILE: src/backend/src/om/entitystorage/ESBuilder.js
================================================
/*
* 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 .
*/
class ESBuilder {
static create (list) {
let stack = [];
let head = null;
const apply_next = () => {
const args = [];
let last_was_cons = false;
while ( !last_was_cons ) {
const item = stack.pop();
if ( typeof item === 'function' ) {
last_was_cons = true;
}
args.unshift(item);
}
const cls = args.shift();
head = new cls({
...(args[0] ?? {}),
...(head ? { upstream: head } : {}),
});
};
for ( const item of list ) {
const is_cons = typeof item === 'function';
if ( is_cons ) {
if ( stack.length > 0 ) apply_next();
}
stack.push(item);
}
if ( stack.length > 0 ) apply_next();
// Print the classes in order
let current = head;
while ( current ) {
current = current.upstream;
}
return head;
}
}
module.exports = {
ESBuilder,
};
================================================
FILE: src/backend/src/om/entitystorage/Entity.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature');
class Entity extends AdvancedBase {
static FEATURES = [
new WeakConstructorFeature(),
];
constructor (args) {
super(args);
this.init_arg_keys_ = Object.keys(args);
this.found = undefined;
this.private_meta = {};
this.values_ = {};
}
static async create (args, data) {
const entity = new Entity(args);
for ( const prop of Object.values(args.om.properties) ) {
if ( ! data.hasOwnProperty(prop.name) ) continue;
await entity.set(prop.name, data[prop.name]);
}
return entity;
}
async clone () {
const args = {};
for ( const k of this.init_arg_keys_ ) {
args[k] = this[k];
}
const entity = new Entity(args);
const BEHAVIOUR = 'A';
if ( BEHAVIOUR === 'A' ) {
entity.found = this.found;
entity.private_meta = { ...this.private_meta };
entity.values_ = { ...this.values_ };
}
if ( BEHAVIOUR === 'B' ) {
for ( const prop of Object.values(this.om.properties) ) {
if ( ! this.has(prop.name) ) continue;
await entity.set(prop.name, await this.get(prop.name));
}
}
return entity;
}
async apply (other) {
for ( const prop of Object.values(this.om.properties) ) {
if ( ! await other.has(prop.name) ) continue;
await this.set(prop.name, await other.get(prop.name));
}
return this;
}
async set (key, value) {
const prop = this.om.properties[key];
if ( ! prop ) {
throw Error(`property ${key} unrecognized`);
}
this.values_[key] = await prop.adapt(value);
}
async get (key) {
const prop = this.om.properties[key];
if ( ! prop ) {
throw Error(`property ${key} unrecognized`);
}
let value = this.values_[key];
let is_set = await prop.is_set(value);
// If value is not set but we have a factory, use it.
if ( ! is_set ) {
value = await prop.factory();
value = await prop.adapt(value);
is_set = await prop.is_set(value);
if ( is_set ) this.values_[key] = value;
}
// If value is not set but we have an implicator, use it.
if ( !is_set && prop.descriptor.imply ) {
const { given, make } = prop.descriptor.imply;
let imply_available = true;
for ( const g of given ) {
if ( ! await this.has(g) ) {
imply_available = false;
break;
}
}
if ( imply_available ) {
value = await make(this.values_);
value = await prop.adapt(value);
is_set = await prop.is_set(value);
}
if ( is_set ) this.values_[key] = value;
}
return value;
}
async del (key) {
const prop = this.om.properties[key];
if ( ! prop ) {
throw Error(`property ${key} unrecognized`);
}
delete this.values_[key];
}
async has (key) {
const prop = this.om.properties[key];
if ( ! prop ) {
throw Error(`property ${key} unrecognized`);
}
return await prop.is_set(await this.get(key));
}
async check (condition) {
return await condition.check(this);
}
om_has_property (key) {
return this.om.properties.hasOwnProperty(key);
}
// alias for `has`
async is_set (key) {
return await this.has(key);
}
async get_client_safe () {
return await this.om.get_client_safe(this.values_);
}
}
module.exports = {
Entity,
};
================================================
FILE: src/backend/src/om/entitystorage/MaxLimitES.js
================================================
/*
* 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 .
*/
const { BaseES } = require('./BaseES');
class MaxLimitES extends BaseES {
static METHODS = {
async select (options) {
let limit = options.limit;
// `limit` is numeric but a value of 0 doesn't make sense,
// so we can treat 0 and undefined as the same case.
if ( ! limit ) {
limit = this.max;
}
if ( limit > this.max ) {
limit = this.max;
}
options.limit = limit;
return await this.upstream.select(options);
},
};
}
module.exports = {
MaxLimitES,
};
================================================
FILE: src/backend/src/om/entitystorage/NotificationES.js
================================================
/*
* 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 .
*/
const { Eq, IsNotNull } = require('../query/query');
const { BaseES } = require('./BaseES');
class NotificationES extends BaseES {
static METHODS = {
async create_predicate (id) {
if ( id === 'unseen' ) {
return new Eq({
key: 'shown',
value: null,
}).and(new Eq({
key: 'acknowledge',
value: null,
}));
}
if ( id === 'unacknowledge' ) {
return new Eq({
key: 'acknowledge',
value: null,
});
}
if ( id === 'acknowledge' ) {
return new IsNotNull({
key: 'acknowledge',
});
}
},
async read_transform (entity) {
let value = await entity.get('value');
if ( typeof value === 'string' ) {
value = JSON.parse(value);
}
if ( ! value ) {
value = {};
}
await entity.set('value', value);
},
};
}
module.exports = { NotificationES };
================================================
FILE: src/backend/src/om/entitystorage/OwnerLimitedES.js
================================================
/*
* 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 .
*/
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const { Eq } = require('../query/query');
const { BaseES } = require('./BaseES');
class OwnerLimitedES extends BaseES {
// Limit selection to entities owned by the app of the current actor.
async select (options) {
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
return [];
}
let condition = new Eq({
key: 'owner',
value: actor.type.user.id,
});
options.predicate = options.predicate?.and
? options.predicate.and(condition)
: condition;
return await this.upstream.select(options);
}
// Limit read to entities owned by the app of the current actor.
async read (uid) {
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
return null;
}
const entity = await this.upstream.read(uid);
if ( ! entity ) return null;
const entity_owner = await entity.get('owner');
let owner_id = entity_owner?.id;
if ( entity_owner.id !== actor.type.user.id ) {
return null;
}
return entity;
}
}
module.exports = {
OwnerLimitedES,
};
================================================
FILE: src/backend/src/om/entitystorage/ProtectedAppES.js
================================================
/*
* 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 .
*/
const { AppUnderUserActorType, UserActorType } = require('../../services/auth/Actor');
const { PermissionUtil } = require('../../services/auth/permissionUtils.mjs');
const { Context } = require('../../util/context');
const { BaseES } = require('./BaseES');
class ProtectedAppES extends BaseES {
async select (options) {
const results = await this.upstream.select(options);
const actor = Context.get('actor');
const services = Context.get('services');
for ( let i = 0 ; i < results.length ; i++ ) {
const entity = results[i];
if ( ! await this.check_({ actor, services }, entity) ) {
continue;
}
results[i] = undefined;
}
return results.filter(e => e !== undefined);
}
async read (uid) {
const entity = await this.upstream.read(uid);
if ( ! entity ) return null;
const actor = Context.get('actor');
const services = Context.get('services');
if ( await this.check_({ actor, services }, entity) ) {
return null;
}
return entity;
}
/**
* returns true if the entity should not be sent downstream
*/
async check_ ({ actor, services }, entity) {
// track: ruleset
{
// if it's not a protected app, no worries
if ( ! await entity.get('protected') ) return;
// if actor is this app, no worries
if (
actor.type instanceof AppUnderUserActorType &&
await entity.get('uid') === actor.type.app.uid
) return;
// if actor is owner of this app, no worries
if (
actor.type instanceof UserActorType &&
(await entity.get('owner')).id === actor.type.user.id
) return;
}
// now we need to check for permission
const app_uid = await entity.get('uid');
const svc_permission = services.get('permission');
const permission_to_check = `app:uid#${app_uid}:access`;
const reading = await svc_permission.scan(actor, permission_to_check);
const options = PermissionUtil.reading_to_options(reading);
if ( options.length > 0 ) return;
// `true` here means "do not send downstream"
return true;
}
};
module.exports = {
ProtectedAppES,
};
================================================
FILE: src/backend/src/om/entitystorage/ReadOnlyES.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { BaseES } = require('./BaseES');
class ReadOnlyES extends BaseES {
async upsert () {
throw APIError.create('forbidden');
}
async delete () {
throw APIError.create('forbidden');
}
}
module.exports = ReadOnlyES;
================================================
FILE: src/backend/src/om/entitystorage/SQLES.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { BaseES } = require('./BaseES');
const APIError = require('../../api/APIError');
const { Entity } = require('./Entity');
const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature');
const { And, Or, Eq, Like, Null, Predicate, PredicateUtil, IsNotNull, StartsWith } = require('../query/query');
const { DB_WRITE } = require('../../services/database/consts');
const { safeHasOwnProperty } = require('../../util/safety');
const { ParallelTasks } = require('../../util/otelutil');
const opentelemetry = require('@opentelemetry/api');
class RawCondition extends AdvancedBase {
// properties: sql:string, values:any[]
static FEATURES = [
new WeakConstructorFeature(),
];
}
class SQLES extends BaseES {
async _on_context_provided () {
const services = this.context.get('services');
this.db = services.get('database').get(DB_WRITE, 'entity-storage');
}
static METHODS = {
async create_predicate (id, args) {
if ( id === 'raw-sql-condition' ) {
return new RawCondition(args);
}
},
async read (uid) {
const [stmt_where, where_vals] = await (async () => {
if ( typeof uid !== 'object' ) {
const id_prop =
this.om.properties[this.om.primary_identifier];
let id_col =
id_prop.descriptor.sql?.column_name ?? id_prop.name;
// Temporary hack until multiple identifiers are supported
// (allows us to query using an internal ID; users can't do this)
if ( typeof uid === 'number' ) {
id_col = 'id';
}
return [` WHERE ${id_col} = ?`, [uid]];
}
if ( ! Object.prototype.hasOwnProperty.call(uid, 'predicate') ) {
throw new Error('SQLES.read does not understand this input: ' +
'object with no predicate property');
}
let predicate = uid.predicate; // uid is actually a predicate
if ( predicate instanceof Predicate ) {
predicate = await this.om_to_sql_condition_(predicate);
}
const stmt_where = ` WHERE ${predicate.sql} LIMIT 1` ;
const where_vals = predicate.values;
return [stmt_where, where_vals];
})();
const stmt =
`SELECT * FROM ${this.om.sql.table_name}${stmt_where}`;
const rows = await this.db.read(stmt, where_vals);
if ( rows.length === 0 ) {
return null;
}
const data = rows[0];
const entity = await this.sql_row_to_entity_(data);
return entity;
},
async select ({ predicate, limit, offset }) {
if ( predicate instanceof Predicate ) {
predicate = await this.om_to_sql_condition_(predicate);
}
const stmt_where = predicate ? ` WHERE ${predicate.sql}` : '';
let stmt =
`SELECT * FROM ${this.om.sql.table_name}${stmt_where}`;
if ( offset !== undefined && limit === undefined ) {
throw new Error('Cannot use offset without limit');
}
if ( limit ) {
stmt += ` LIMIT ${limit}`;
}
if ( offset ) {
stmt += ` OFFSET ${offset}`;
}
const values = [];
if ( predicate ) values.push(...(predicate.values || []));
const rows = await this.db.read(stmt, values);
const entities = await Promise.all(rows.map(async (data) => {
return await this.sql_row_to_entity_(data);
}));
return entities;
},
async upsert (entity, extra) {
const { old_entity } = extra;
// Check unique constraints
for ( const prop of Object.values(this.om.properties) ) {
const options = prop.descriptor.sql ?? {};
if ( ! prop.descriptor.unique ) continue;
const col_name = options.column_name ?? prop.name;
const value = await entity.get(prop.name);
const values = [];
let stmt =
`SELECT COUNT(*) FROM ${this.om.sql.table_name} WHERE ${col_name} = ?`;
values.push(value);
if ( old_entity ) {
stmt += ' AND id != ?';
values.push(old_entity.private_meta.mysql_id);
}
const rows = await this.db.read(stmt, values);
const count = rows[0]['COUNT(*)'];
if ( count > 0 ) {
throw APIError.create('already_in_use', null, {
what: prop.name,
value,
});
}
}
// Update or create
if ( old_entity ) {
const result = await this.update_(entity, old_entity);
result.insert_id = old_entity.private_meta.mysql_id;
return result;
} else {
return await this.create_(entity);
}
},
async delete (uid) {
const id_prop = this.om.properties[this.om.primary_identifier];
let id_col =
id_prop.descriptor.sql?.column_name ?? id_prop.name;
const stmt =
`DELETE FROM ${this.om.sql.table_name} WHERE ${id_col} = ?`;
const res = await this.db.write(stmt, [uid]);
if ( ! res.anyRowsAffected ) {
throw APIError.create('entity_not_found', null, {
'identifier': uid,
});
}
return {
data: {},
};
},
async sql_row_to_entity_ (data) {
const entity_data = {};
const tasks = new ParallelTasks({ tracer: opentelemetry.trace.getTracer('sqles') });
for ( const prop of Object.values(this.om.properties) ) {
const options = prop.descriptor.sql ?? {};
if ( options.ignore ) {
continue;
}
const col_name = options.column_name ?? prop.name;
if ( ! safeHasOwnProperty(data, col_name) ) {
continue;
}
let value = data[col_name];
tasks.add(`sql_row_to_entity_::${prop.name}`, async () => {
value = await prop.sql_dereference(value);
if ( prop.typ.name === 'json' ) {
value = this.db.case({
mysql: () => value,
otherwise: () => JSON.parse(value ?? '{}'),
})();
}
entity_data[prop.name] = value;
});
}
await tasks.awaitAll();
const entity = await Entity.create({ om: this.om }, entity_data);
entity.private_meta.mysql_id = data.id;
return entity;
},
async create_ (entity) {
const sql_data = await this.get_sql_data_(entity);
const sql_cols = Object.keys(sql_data).join(', ');
const sql_placeholders = Object.keys(sql_data).map(() => '?').join(', ');
const execute_vals = Object.values(sql_data);
const stmt =
`INSERT INTO ${this.om.sql.table_name} (${sql_cols}) VALUES (${sql_placeholders})`;
// Very useful when debugging! Keep these here but commented out.
// console.log('SQL STMT', stmt);
// console.log('SQL VALS', execute_vals);
const res = await this.db.write(stmt, execute_vals);
return {
data: sql_data,
entity,
insert_id: res.insertId,
};
},
async update_ (entity, old_entity) {
const sql_data = await this.get_sql_data_(entity);
const id_value = await entity.get(this.om.primary_identifier);
delete sql_data[this.om.primary_identifier];
const sql_assignments = Object.keys(sql_data).map((col_name) => {
return `${col_name} = ?`;
}).join(', ');
const execute_vals = Object.values(sql_data);
const id_prop = this.om.properties[this.om.primary_identifier];
const id_col =
id_prop.descriptor.sql?.column_name ?? id_prop.name;
const stmt =
`UPDATE ${this.om.sql.table_name} SET ${sql_assignments} WHERE ${id_col} = ?`;
execute_vals.push(id_value);
// Very useful when debugging! Keep these here but commented out.
// console.log('SQL STMT', stmt);
// console.log('SQL VALS', execute_vals);
await this.db.write(stmt, execute_vals);
const full_entity = await (await old_entity.clone()).apply(entity);
return {
data: sql_data,
entity: full_entity,
};
},
async get_sql_data_ (entity) {
const sql_data = {};
for ( const prop of Object.values(this.om.properties) ) {
const options = prop.descriptor.sql ?? {};
if ( ! await entity.has(prop.name) ) {
continue;
}
if ( options.ignore ) {
continue;
}
const col_name = options.column_name ?? prop.name;
let value = await entity.get(prop.name);
if ( value === undefined ) {
continue;
}
value = await prop.sql_reference(value);
// TODO: This is done here for consistency;
// see the larger comment in sql_row_to_entity_
// which does the reverse operation.
if ( prop.typ.name === 'json' ) {
value = JSON.stringify(value);
}
if ( value && options.use_id ) {
if ( Object.prototype.hasOwnProperty.call(value, 'id') ) {
value = value.id;
}
}
sql_data[col_name] = value;
}
return sql_data;
},
async om_to_sql_condition_ (om_query) {
om_query = PredicateUtil.simplify(om_query);
if ( om_query instanceof Null ) {
return undefined;
}
if ( om_query instanceof And ) {
const child_raw_conditions = [];
const values = [];
for ( const child of om_query.children ) {
// if ( child instanceof Null ) continue;
const sql_condition = await this.om_to_sql_condition_(child);
child_raw_conditions.push(sql_condition.sql);
values.push(...(sql_condition.values || []));
}
const sql = child_raw_conditions.map((sql) => {
return `(${sql})`;
}).join(' AND ');
return new RawCondition({ sql, values });
}
if ( om_query instanceof Or ) {
const child_raw_conditions = [];
const values = [];
for ( const child of om_query.children ) {
// if ( child instanceof Null ) continue;
const sql_condition = await this.om_to_sql_condition_(child);
child_raw_conditions.push(sql_condition.sql);
values.push(...(sql_condition.values || []));
}
const sql = child_raw_conditions.map((sql) => {
return `(${sql})`;
}).join(' OR ');
return new RawCondition({ sql, values });
}
if ( om_query instanceof Eq ) {
const key = om_query.key;
let value = om_query.value;
const prop = this.om.properties[key];
value = await prop.sql_reference(value);
const options = prop.descriptor.sql ?? {};
const col_name = options.column_name ?? prop.name;
const sql = value === null ? `${col_name} IS NULL` : `${col_name} = ?`;
const values = value === null ? [] : [value];
return new RawCondition({ sql, values });
}
if ( om_query instanceof StartsWith ) {
const key = om_query.key;
let value = om_query.value;
const prop = this.om.properties[key];
value = await prop.sql_reference(value);
const options = prop.descriptor.sql ?? {};
const col_name = options.column_name ?? prop.name;
const sql = `${col_name} LIKE ${this.db.case({
sqlite: '? || \'%\'',
otherwise: 'CONCAT(?, \'%\')',
})}`;
const values = value === null ? [] : [value];
return new RawCondition({ sql, values });
}
if ( om_query instanceof IsNotNull ) {
const key = om_query.key;
let value = om_query.value;
const prop = this.om.properties[key];
value = await prop.sql_reference(value);
const options = prop.descriptor.sql ?? {};
const col_name = options.column_name ?? prop.name;
const sql = `${col_name} IS NOT NULL`;
const values = [value];
return new RawCondition({ sql, values });
}
if ( om_query instanceof Like ) {
const key = om_query.key;
let value = om_query.value;
const prop = this.om.properties[key];
value = await prop.sql_reference(value);
const options = prop.descriptor.sql ?? {};
const col_name = options.column_name ?? prop.name;
const sql = `${col_name} LIKE ?`;
const values = [value];
return new RawCondition({ sql, values });
}
},
};
}
module.exports = SQLES;
================================================
FILE: src/backend/src/om/entitystorage/SetOwnerES.js
================================================
/*
* 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 .
*/
const { get_user } = require('../../helpers');
const { AppUnderUserActorType, UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const { BaseES } = require('./BaseES');
class SetOwnerES extends BaseES {
static METHODS = {
async upsert (entity, extra) {
const { old_entity } = extra;
if ( ! old_entity ) {
await entity.set('owner', Context.get('user'));
if ( entity.om_has_property('app_owner') ) {
const actor = Context.get('actor');
if ( actor.type instanceof AppUnderUserActorType ) {
const app = actor.type.app;
// We need to escalate privileges to set the app owner
// because the app may not have permission to read
// its own entry from es:app.
const upgraded_actor = actor.get_related_actor(UserActorType);
await Context.get().sub({
actor: upgraded_actor,
}).arun(async () => {
await entity.set('app_owner', app.uid);
});
}
}
}
return await this.upstream.upsert(entity, extra);
},
async read (uid) {
const entity = await this.upstream.read(uid);
if ( ! entity ) return null;
await this._sanitize_owner(entity);
return entity;
},
async select (...args) {
const entities = await this.upstream.select(...args);
for ( const entity of entities ) {
await this._sanitize_owner(entity);
}
return entities;
},
async _sanitize_owner (entity) {
let owner = await entity.get('owner');
if ( ! owner ) return null;
owner = get_user({ id: owner });
await entity.set('owner', owner);
},
};
}
module.exports = {
SetOwnerES,
};
================================================
FILE: src/backend/src/om/entitystorage/SubdomainES.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const config = require('../../config');
const { DB_READ } = require('../../services/database/consts');
const { Context } = require('../../util/context');
const { Eq } = require('../query/query');
const { BaseES } = require('./BaseES');
const PERM_READ_ALL_SUBDOMAINS = 'read-all-subdomains';
class SubdomainES extends BaseES {
async _on_context_provided () {
const services = this.context.get('services');
this.db = services.get('database').get(DB_READ, 'subdomains');
}
async create_predicate (id) {
if ( id === 'user-can-edit' ) {
return new Eq({
key: 'owner',
value: Context.get('user').id,
});
}
}
async upsert (entity, extra) {
if ( ! extra.old_entity ) {
await this._check_max_subdomains();
}
return await this.upstream.upsert(entity, extra);
}
async select (options) {
const actor = Context.get('actor');
const user = actor.type.user;
// Note: we don't need to worry about read;
// non-owner users don't have permission to list
// but they still have permission to read.
const svc_permission = this.context.get('services').get('permission');
const has_permission_to_read_all = await svc_permission.check(Context.get('actor'), PERM_READ_ALL_SUBDOMAINS);
if ( ! has_permission_to_read_all ) {
options.predicate = options.predicate.and(new Eq({
key: 'owner',
value: user.id,
}));
}
return await this.upstream.select(options);
}
async _check_max_subdomains () {
const user = Context.get('user');
let cnt = await this.db.read('SELECT COUNT(id) AS subdomain_count FROM subdomains WHERE user_id = ?',
[user.id]);
const max_subdomains = user.max_subdomains ?? config.max_subdomains_per_user;
if ( max_subdomains && cnt[0].subdomain_count >= max_subdomains ) {
throw APIError.create('subdomain_limit_reached', null, {
limit: max_subdomains,
});
}
};
}
module.exports = SubdomainES;
================================================
FILE: src/backend/src/om/entitystorage/ValidationES.js
================================================
/*
* 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 .
*/
const { BaseES } = require('./BaseES');
const APIError = require('../../api/APIError');
const { Context } = require('../../util/context');
const { SKIP_ES_VALIDATION } = require('./consts');
class ValidationES extends BaseES {
async _on_context_provided () {
// const services = this.context.get('services');
// const svc_mysql = services.get('mysql');
// this.dbrw = svc_mysql.get(DB_MODE_WRITE, `es:${this.entity_name}:rw`);
// this.dbrr = svc_mysql.get(DB_MODE_WRITE, `es:${this.entity_name}:rr`);
}
static METHODS = {
// async create (entity) {
// await this.validate_(entity);
// return await this.om.get_client_safe((await this.upstream.create(entity)).data);
// },
// async update (entity) {
// await this.validate_(entity);
// return await this.om.get_client_safe((await this.upstream.update(entity)).data);
// },
async upsert (entity, extra) {
for ( const prop of Object.values(this.om.properties) ) {
if (
prop.descriptor.protected ||
prop.descriptor.read_only
) {
await entity.del(prop.name);
}
}
const valid_entity = extra.old_entity
? await (await extra.old_entity.clone()).apply(entity)
: entity
;
await this.validate_(valid_entity,
extra.old_entity ? entity : undefined);
const { entity: out_entity } = await this.upstream.upsert(entity, extra);
return await out_entity.get_client_safe();
},
async validate_ (entity, diff) {
if ( Context.get(SKIP_ES_VALIDATION) ) return;
for ( const prop of Object.values(this.om.properties) ) {
let value = await entity.get(prop.name);
if ( prop.descriptor.required ) {
if ( ! await entity.is_set(prop.name) ) {
throw APIError.create('field_missing', null, { key: prop.name });
}
}
if ( ! await entity.is_set(prop.name) ) continue;
if ( prop.descriptor.immutable && diff && await diff.has(prop.name) ) {
throw APIError.create('field_immutable', null, { key: prop.name });
}
try {
const validation_result = await prop.validate(value);
if ( validation_result !== true ) {
throw validation_result || APIError.create('field_invalid', null, { key: prop.name });
}
} catch ( e ) {
if ( ! (e instanceof APIError) ) {
// eslint-disable-next-line no-ex-assign
e = APIError.create('field_invalid', null, {
key: prop.name,
converted_from_another_error: true,
});
}
throw e;
}
}
},
};
}
module.exports = ValidationES;
================================================
FILE: src/backend/src/om/entitystorage/WriteByOwnerOnlyES.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { Context } = require('../../util/context');
const { BaseES } = require('./BaseES');
const WRITE_ALL_OWNER_ES = 'system:es:write-all-owners';
/**
* Entity storage layer that restricts write operations to entity owners only.
* Extends BaseES to add ownership-based access control for upsert and delete operations.
*/
class WriteByOwnerOnlyES extends BaseES {
/**
* Static methods object containing the access-controlled entity storage operations.
*/
static METHODS = {
/**
* Updates or inserts an entity after verifying ownership permissions.
* @param {Object} entity - The entity to upsert
* @param {Object} extra - Additional parameters including old_entity
* @returns {Promise} Result of the upstream upsert operation
*/
async upsert (entity, extra) {
const { old_entity } = extra;
if ( old_entity ) {
await this._check_allowed({ old_entity });
}
return await this.upstream.upsert(entity, extra);
},
/**
* Deletes an entity after verifying the current user owns it.
* @param {string} uid - The unique identifier of the entity to delete
* @param {Object} extra - Additional parameters including old_entity
* @returns {Promise} Result of the upstream delete operation
*/
async delete (uid, extra) {
const { old_entity } = extra;
// Owner check is required first
await this._check_allowed({ old_entity: extra.old_entity });
return await this.upstream.delete(uid, extra);
},
/**
* Verifies that the current user has permission to modify the entity.
* Allows access if user has system-wide write permission or owns the entity.
* @param {Object} params - Parameters object
* @param {Object} params.old_entity - The existing entity to check ownership for
* @throws {APIError} Throws forbidden error if user lacks permission
*/
async _check_allowed ({ old_entity }) {
const svc_permission = this.context.get('services').get('permission');
const has_permission_to_write_all = await svc_permission.check(Context.get('actor'), WRITE_ALL_OWNER_ES);
if ( has_permission_to_write_all ) {
return;
}
const owner = await old_entity.get('owner');
if ( ! owner ) {
throw APIError.create('forbidden');
}
const user = Context.get('user');
if ( user.id !== owner.id ) {
throw APIError.create('forbidden');
}
},
};
}
module.exports = WriteByOwnerOnlyES;
================================================
FILE: src/backend/src/om/entitystorage/consts.js
================================================
module.exports = {
SKIP_ES_VALIDATION: Symbol('SKIP_ES_VALIDATION'),
};
================================================
FILE: src/backend/src/om/mappings/__all__.js
================================================
/*
* 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 .
*/
module.exports = {
app: require('./app'),
subdomain: require('./subdomain'),
notification: require('./notification'),
};
================================================
FILE: src/backend/src/om/mappings/access-token.js
================================================
/*
* 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 .
*/
module.exports = {
sql: {
table_name: 'access_token_permissions',
},
primary_identifier: 'token',
};
================================================
FILE: src/backend/src/om/mappings/app.js
================================================
/*
* 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 .
*/
const config = require('../../config');
module.exports = {
sql: {
table_name: 'apps',
},
primary_identifier: 'uid',
redundant_identifiers: ['name'],
properties: {
// INHERENT
uid: {
type: 'puter-uuid',
prefix: 'app',
},
// DOMAIN
icon: 'image-base64',
name: {
type: 'string',
required: true,
maxlen: config.app_name_max_length,
regex: config.app_name_regex,
},
title: {
type: 'string',
required: true,
maxlen: config.app_title_max_length,
},
description: {
type: 'string',
// longest description in prod is currently 3444,
// so I've doubled that and rounded up
maxlen: 7000,
},
metadata: {
type: 'json',
},
maximize_on_start: 'flag',
background: 'flag',
subdomain: {
type: 'string',
transient: true,
factory: () => `app-${ require('uuid').v4()}`,
sql: { ignore: true },
},
index_url: {
type: 'url',
required: true,
maxlen: 3000,
imply: {
given: ['subdomain', 'source_directory'],
make: async ({ subdomain }) => {
return `${config.protocol }://${ subdomain }.puter.site`;
},
},
},
source_directory: {
type: 'puter-node',
node_type: 'directory',
sql: { ignore: true },
},
created_at: {
type: 'datetime',
aliases: ['timestamp'],
sql: {
column_name: 'timestamp',
},
},
filetype_associations: {
type: 'array',
of: 'string',
sql: { ignore: true },
},
// DOMAIN :: CALCULATED
stats: {
type: 'json',
sql: { ignore: true },
},
privateAccess: {
type: 'json',
sql: { ignore: true },
},
created_from_origin: {
type: 'string',
sql: { ignore: true },
},
// ACCESS
owner: {
type: 'reference',
to: 'user',
permissions: ['write'], // write = update,delete,create
permissible_subproperties: ['username', 'uuid'],
sql: {
use_id: true,
column_name: 'owner_user_id',
},
},
app_owner: {
type: 'reference',
service: 'es:app',
to: 'app',
sql: { use_id: true },
},
protected: {
type: 'flag',
},
is_private: {
type: 'flag',
read_only: true,
},
// OPERATIONS
last_review: {
type: 'datetime',
protected: true,
},
approved_for_listing: {
type: 'flag',
read_only: true,
},
approved_for_opening_items: {
type: 'flag',
read_only: true,
},
approved_for_incentive_program: {
type: 'flag',
read_only: true,
},
// SYSTEM
godmode: {
type: 'flag',
read_only: true,
},
},
};
================================================
FILE: src/backend/src/om/mappings/notification.js
================================================
/*
* 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 .
*/
module.exports = {
sql: {
table_name: 'notification',
},
primary_identifier: 'uid',
properties: {
uid: { type: 'uuid' },
value: { type: 'json' },
read: { type: 'flag' },
owner: {
type: 'reference',
to: 'user',
permissions: ['read'],
permissible_subproperties: ['username', 'uuid'],
sql: {
use_id: true,
column_name: 'user_id',
},
},
},
};
================================================
FILE: src/backend/src/om/mappings/subdomain.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const config = require('../../config');
module.exports = {
sql: {
table_name: 'subdomains',
},
primary_identifier: 'uid',
redundant_identifiers: ['subdomain'],
properties: {
// INHERENT
uid: {
type: 'puter-uuid',
prefix: 'sd',
sql: { column_name: 'uuid' },
},
// DOMAIN
subdomain: {
type: 'string',
required: true,
immutable: true,
unique: true,
maxlen: config.subdomain_max_length,
regex: config.subdomain_regex,
// TODO: can this 'adapt' be data instead?
async adapt (value) {
return value.toLowerCase();
},
async validate (value) {
if ( config.reserved_words.includes(value) ) {
return APIError.create('subdomain_reserved', null, {
subdomain: value,
});
}
},
},
domain: {
type: 'string',
maxlen: 253,
// It turns out validating domain names kind of sucks
// source: https://stackoverflow.com/questions/10306690
regex: '^(((?!-))(xn--|_)?[a-z0-9-]{0,61}[a-z0-9]{1,1}\.)*(xn--)?([a-z0-9][a-z0-9\-]{0,60}|[a-z0-9-]{1,30}\.[a-z]{2,})$',
// TODO: can this 'adapt' be data instead?
async adapt (value) {
if ( value !== null )
{
return value.toLowerCase();
}
return null;
},
},
root_dir: {
type: 'puter-node',
fs_permission: 'read',
sql: {
column_name: 'root_dir_id',
},
},
associated_app: {
type: 'reference',
service: 'es:app',
to: 'app',
sql: {
use_id: true,
column_name: 'associated_app_id',
},
},
created_at: {
type: 'datetime',
aliases: ['timestamp'],
sql: {
column_name: 'ts',
},
},
// ACCESS
owner: {
type: 'reference',
to: 'user',
permissions: ['write'],
permissible_subproperties: ['username', 'uuid'],
sql: {
use_id: true,
column_name: 'user_id',
},
},
app_owner: {
type: 'reference',
service: 'es:app',
to: 'app',
sql: { use_id: true },
},
protected: {
type: 'flag',
},
},
};
================================================
FILE: src/backend/src/om/proptypes/__all__.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const config = require('../../config');
const { NodeUIDSelector, NodeInternalIDSelector, NodePathSelector } = require('../../filesystem/node/selectors');
const { is_valid_uuid4, is_valid_uuid } = require('../../helpers');
const validator = require('validator');
const { Context } = require('../../util/context');
const { is_valid_path } = require('../../filesystem/validation');
const FSNodeContext = require('../../filesystem/FSNodeContext');
const { Entity } = require('../entitystorage/Entity');
const { APP_ICONS_SUBDOMAIN } = require('../../consts/app-icons');
const NULL = Symbol('NULL');
const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/([^/?#]+)(?:\/(\d+))?\/?$/;
const LEGACY_APP_ICON_FILE_PATH_REGEX = /^\/(app-[^/?#]+?)(?:-(\d+))?\.png$/;
const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
const isAbsoluteUrl = value => ABSOLUTE_URL_REGEX.test(value) || value.startsWith('//');
const isRawBase64ImageString = value => {
if ( typeof value !== 'string' ) return false;
const trimmed = value.trim();
if ( !trimmed || trimmed.length < 16 ) return false;
if ( ! RAW_BASE64_REGEX.test(trimmed) ) return false;
if ( trimmed.length % 4 !== 0 ) return false;
try {
const decoded = Buffer.from(trimmed, 'base64');
if ( decoded.length === 0 ) return false;
const normalizedInput = trimmed.replace(/=+$/, '');
const reencoded = decoded.toString('base64').replace(/=+$/, '');
return normalizedInput === reencoded;
} catch {
return false;
}
};
const normalizeRawBase64ImageString = value => {
if ( typeof value !== 'string' ) return value;
const trimmed = value.trim();
if ( ! isRawBase64ImageString(trimmed) ) return value;
return `data:image/png;base64,${trimmed}`;
};
const isStoredBase64AppIcon = ({ icon, icon_is_base64: iconIsBase64 }) => {
if ( typeof iconIsBase64 === 'boolean' ) return iconIsBase64;
if ( typeof iconIsBase64 === 'number' ) return iconIsBase64 !== 0;
if ( typeof iconIsBase64 === 'string' ) {
const normalized = iconIsBase64.toLowerCase();
if ( normalized === '1' || normalized === 'true' ) return true;
if ( normalized === '0' || normalized === 'false' ) return false;
}
if ( typeof icon !== 'string' ) return false;
const trimmed = icon.trim();
if ( trimmed.startsWith('data:image/') ) return true;
return isRawBase64ImageString(trimmed);
};
const getCanonicalAppIconBaseUrl = () => {
const candidate = [config.api_base_url, config.origin]
.find(value => typeof value === 'string' && value.trim());
if ( ! candidate ) return null;
try {
return (new URL(candidate)).origin;
} catch {
return null;
}
};
const normalizeAppUid = appUid => (
typeof appUid === 'string' && appUid.startsWith('app-')
? appUid
: `app-${appUid}`
);
const parseAppIconEndpointPath = value => {
if ( typeof value !== 'string' ) return null;
const trimmed = value.trim();
if ( ! trimmed ) return null;
try {
const match = new URL(trimmed, 'http://localhost').pathname.match(APP_ICON_ENDPOINT_PATH_REGEX);
if ( ! match ) return null;
return {
appUid: normalizeAppUid(match[1]),
};
} catch {
return null;
}
};
const isAppIconEndpointPath = value => !!parseAppIconEndpointPath(value);
const getAllowedAppIconOrigins = () => {
const origins = new Set();
for ( const candidate of [config.api_base_url, config.origin] ) {
if ( typeof candidate !== 'string' || !candidate ) continue;
try {
origins.add((new URL(candidate)).origin);
} catch {
// Ignore invalid config values.
}
}
return origins;
};
const getAllowedLegacyAppIconHostnames = () => {
const hostnames = new Set();
const domains = [config.static_hosting_domain, config.static_hosting_domain_alt];
for ( const domain of domains ) {
if ( typeof domain !== 'string' || !domain.trim() ) continue;
hostnames.add(`${APP_ICONS_SUBDOMAIN}.${domain.trim().toLowerCase()}`);
}
return hostnames;
};
const isAllowedAppIconEndpointUrl = value => {
if ( ! isAppIconEndpointPath(value) ) return false;
const trimmed = value.trim();
if ( ! isAbsoluteUrl(trimmed) ) {
return true;
}
try {
const parsed = new URL(trimmed, 'http://localhost');
return getAllowedAppIconOrigins().has(parsed.origin);
} catch {
return false;
}
};
const parseLegacyHostedAppIconToEndpointPath = value => {
if ( typeof value !== 'string' ) return null;
const trimmed = value.trim();
if ( !trimmed || trimmed.startsWith('data:') ) return null;
let parsed;
try {
parsed = new URL(trimmed, 'http://localhost');
} catch {
return null;
}
if ( isAbsoluteUrl(trimmed) ) {
const allowedHostnames = getAllowedLegacyAppIconHostnames();
const hostname = parsed.hostname.toLowerCase();
if ( ! allowedHostnames.has(hostname) ) {
return null;
}
}
const match = parsed.pathname.match(LEGACY_APP_ICON_FILE_PATH_REGEX);
if ( ! match ) return null;
const appUid = normalizeAppUid(match[1]);
return `/app-icon/${appUid}`;
};
const migrateRelativeAppIconEndpointUrl = value => {
if ( typeof value !== 'string' ) return value;
const trimmed = value.trim();
if ( ! trimmed ) return value;
let canonicalEndpointPath = null;
const endpointPath = parseAppIconEndpointPath(trimmed);
if ( endpointPath ) {
if ( isAbsoluteUrl(trimmed) ) {
try {
const parsed = new URL(trimmed, 'http://localhost');
if ( ! getAllowedAppIconOrigins().has(parsed.origin) ) {
return value;
}
} catch {
return value;
}
}
canonicalEndpointPath = `/app-icon/${endpointPath.appUid}`;
} else {
canonicalEndpointPath = parseLegacyHostedAppIconToEndpointPath(trimmed);
}
if ( ! canonicalEndpointPath ) return value;
const baseUrl = getCanonicalAppIconBaseUrl();
if ( ! baseUrl ) return canonicalEndpointPath;
try {
return new URL(canonicalEndpointPath, `${baseUrl}/`).toString();
} catch {
return canonicalEndpointPath;
}
};
class OMTypeError extends Error {
constructor ({ expected, got }) {
const message = `expected ${expected}, got ${got}`;
super(message);
this.name = 'OMTypeError';
}
}
module.exports = {
base: {
is_set (value) {
return !!value;
},
},
json: {
from: 'base',
},
string: {
is_set (value) {
return (!!value) || value === null;
},
async adapt (value) {
if ( value === undefined ) return '';
// SQL stores strings as null. If one-way adapt from db is supported
// then this should become an sql-to-entity adapt only.
if ( value === null ) return '';
if ( value === NULL ) {
return null;
}
if ( typeof value !== 'string' ) {
throw new OMTypeError({ expected: 'string', got: typeof value });
}
return value;
},
validate (value, { name, descriptor }) {
if ( typeof value !== 'string' ) {
return new OMTypeError({ expected: 'string', got: typeof value });
}
if ( Object.prototype.hasOwnProperty.call(descriptor, 'maxlen') && value.length > descriptor.maxlen ) {
throw APIError.create('field_too_long', null, { key: name, max_length: descriptor.maxlen });
}
if ( Object.prototype.hasOwnProperty.call(descriptor, 'minlen') && value.length > descriptor.minlen ) {
throw APIError.create('field_too_short', null, { key: name, min_length: descriptor.maxlen });
}
if ( Object.prototype.hasOwnProperty.call(descriptor, 'regex') && !value.match(descriptor.regex) ) {
return new Error(`string does not match regex ${descriptor.regex}`);
}
return true;
},
},
array: {
from: 'base',
validate (value, { name, descriptor }) {
if ( ! Array.isArray(value) ) {
return new OMTypeError({ expected: 'array', got: typeof value });
}
if ( Object.prototype.hasOwnProperty.call(descriptor, 'maxlen') && value.length > descriptor.maxlen ) {
throw APIError.create('field_too_long', null, { key: name, max_length: descriptor.maxlen });
}
if ( Object.prototype.hasOwnProperty.call(descriptor, 'minlen') && value.length > descriptor.minlen ) {
throw APIError.create('field_too_short', null, { key: name, min_length: descriptor.maxlen });
}
if ( Object.prototype.hasOwnProperty.call(descriptor, 'mod') && value.length % descriptor.mod !== 0 ) {
throw APIError.create('field_invalid', null, { key: name, mod: descriptor.mod });
}
return true;
},
},
flag: {
adapt: value => {
if ( value === undefined ) return false;
if ( value === 0 ) value = false;
if ( value === 1 ) value = true;
if ( value === '0' ) value = false;
if ( value === '1' ) value = true;
if ( typeof value !== 'boolean' ) {
throw new OMTypeError({ expected: 'boolean', got: typeof value });
}
return value;
},
},
uuid: {
from: 'string',
validate (value) {
return is_valid_uuid4(value);
},
},
'puter-uuid': {
from: 'string',
validate (value, { descriptor }) {
const prefix = `${descriptor.prefix }-`;
if ( ! value.startsWith(prefix) ) {
return new Error(`UUID does not start with prefix ${prefix}`);
}
return is_valid_uuid(value.slice(prefix.length));
},
factory ({ descriptor }) {
const prefix = `${descriptor.prefix }-`;
const uuid = require('uuid').v4();
return prefix + uuid;
},
},
'image-base64': {
from: 'string',
is_set (value) {
return typeof value === 'string' && value.trim().length > 0;
},
adapt (value) {
if ( value === NULL ) return null;
if ( value === undefined || value === null ) return '';
if ( typeof value !== 'string' ) {
throw new OMTypeError({ expected: 'string', got: typeof value });
}
value = normalizeRawBase64ImageString(value);
if ( isStoredBase64AppIcon({ icon: value }) ) {
return value;
}
return migrateRelativeAppIconEndpointUrl(value);
},
validate (value) {
if ( typeof value !== 'string' ) {
return new OMTypeError({ expected: 'string', got: typeof value });
}
const trimmed = value.trim();
if ( ! trimmed ) {
return true;
}
if ( isStoredBase64AppIcon({ icon: trimmed }) ) {
// XSS characters
const chars = ['<', '>', '&', '"', "'", '`'];
if ( chars.some(char => trimmed.includes(char)) ) {
return new Error('icon is not an image');
}
return true;
}
if ( isAllowedAppIconEndpointUrl(trimmed) ) {
return true;
}
return new Error('icon must be base64 encoded or an app-icon endpoint URL');
},
},
url: {
from: 'string',
validate (value) {
let valid = validator.isURL(value);
if ( ! valid ) {
valid = validator.isURL(value, { host_whitelist: ['localhost'] });
}
return valid;
},
},
reference: {
from: 'base',
async sql_reference (value, { descriptor }) {
if ( ! descriptor.service ) return value;
if ( ! value ) return null;
if ( value instanceof Entity ) {
return value.private_meta.mysql_id;
}
return value.id;
},
async sql_dereference (value, { descriptor }) {
if ( ! descriptor.service ) return value;
if ( ! value ) return null;
const svc = Context.get().get('services').get(descriptor.service);
const entity = await svc.read(value);
return entity;
},
async adapt (value, { descriptor }) {
if ( ! descriptor.service ) return value;
if ( ! value ) return null;
if ( value instanceof Entity ) return value;
const svc = Context.get().get('services').get(descriptor.service);
const entity = await svc.read(value);
return entity;
},
},
datetime: {
from: 'base',
},
'puter-node': {
// from: 'base',
async sql_reference (value) {
if ( value === null ) return null;
if ( ! (value instanceof FSNodeContext) ) {
throw new Error('Cannot reference non-FSNodeContext');
}
await value.fetchEntry();
return value.mysql_id ?? null;
},
async is_set (value) {
return ( !!value ) || value === null;
},
async sql_dereference (value) {
if ( value === null ) return null;
if ( typeof value !== 'number' ) {
throw new Error(`Cannot dereference non-number: ${value}`);
}
const svc_fs = Context.get().get('services').get('filesystem');
return svc_fs.node(new NodeInternalIDSelector('mysql', value));
},
async adapt (value, { name }) {
if ( value === null ) return null;
if ( value instanceof FSNodeContext ) {
return value;
}
const ctx = Context.get();
if ( typeof value !== 'string' ) return;
let selector;
if ( ! ['/', '.', '~'].includes(value[0]) ) {
if ( is_valid_uuid4(value) ) {
selector = new NodeUIDSelector(value);
}
} else {
if ( value.startsWith('~') ) {
const user = ctx.get('user');
if ( ! user ) {
throw new Error('Cannot use ~ without a user');
}
const homedir = `/${user.username}`;
value = homedir + value.slice(1);
}
if ( ! is_valid_path(value) ) {
throw APIError.create('field_invalid', null, {
key: name,
expected: 'unix-style path or UUID',
});
}
selector = new NodePathSelector(value);
}
const svc_fs = ctx.get('services').get('filesystem');
const node = await svc_fs.node(selector);
return node;
},
async validate (value, { descriptor }) {
if ( value === null ) return;
const actor = Context.get('actor');
const permission = descriptor.fs_permission ?? 'see';
const svc_acl = Context.get('services').get('acl');
if ( await value.get('path') === '/' ) {
return APIError.create('forbidden');
}
if ( ! await svc_acl.check(actor, value, permission) ) {
return await svc_acl.get_safe_acl_error(actor, value, permission);
}
},
},
NULL,
};
================================================
FILE: src/backend/src/om/proptypes/__all__.test.js
================================================
import { beforeAll, describe, expect, it } from 'vitest';
const proptypes = require('./__all__');
const config = require('../../config');
describe('OM image-base64 proptype', () => {
const validateIcon = proptypes['image-base64'].validate;
const adaptIcon = proptypes['image-base64'].adapt;
beforeAll(() => {
config.origin = 'https://puter.localhost';
config.api_base_url = 'https://api.puter.localhost';
config.static_hosting_domain = 'puter.site';
});
it('accepts data URL icons', () => {
expect(validateIcon('data:image/png;base64,abc123')).toBe(true);
});
it('accepts raw base64 icon strings', () => {
expect(validateIcon('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ')).toBe(true);
});
it('accepts absolute app-icon endpoint URLs', () => {
expect(validateIcon('https://api.puter.localhost/app-icon/app-uid-123/64')).toBe(true);
});
it('accepts absolute app-icon endpoint URLs without size', () => {
expect(validateIcon('https://api.puter.localhost/app-icon/app-uid-123')).toBe(true);
});
it('accepts relative app-icon endpoint paths', () => {
expect(validateIcon('/app-icon/app-uid-123/64')).toBe(true);
});
it('accepts relative app-icon endpoint paths without size', () => {
expect(validateIcon('/app-icon/app-uid-123')).toBe(true);
});
it('migrates relative app-icon endpoint paths to absolute URLs', () => {
expect(adaptIcon('/app-icon/app-uid-123/64')).toBe('https://api.puter.localhost/app-icon/app-uid-123');
});
it('normalizes raw base64 icon strings to png data URLs', () => {
expect(adaptIcon('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'))
.toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ');
});
it('migrates legacy app-icons host URLs to absolute app-icon endpoint URLs', () => {
expect(adaptIcon('https://puter-app-icons.puter.site/app-uid-123-64.png'))
.toBe('https://api.puter.localhost/app-icon/app-uid-123');
});
it('treats empty icon as valid', () => {
expect(validateIcon('')).toBe(true);
});
it('adapts null icon to empty string', () => {
expect(adaptIcon(null)).toBe('');
});
it('accepts relative app-icon endpoint paths with query params', () => {
expect(validateIcon('/app-icon/app-uid-123/64?v=123')).toBe(true);
});
it('rejects invalid icon values', () => {
expect(validateIcon('not-an-icon')).toBeInstanceOf(Error);
});
it('rejects object icon values', () => {
expect(validateIcon({ url: '/app-icon/app-uid-123/64' })).toBeInstanceOf(Error);
});
it('rejects foreign absolute app-icon endpoint URLs', () => {
expect(validateIcon('https://evil.example/app-icon/app-uid-123/64')).toBeInstanceOf(Error);
});
});
================================================
FILE: src/backend/src/om/query/query.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature');
class Predicate extends AdvancedBase {
static FEATURES = [
new WeakConstructorFeature(),
];
}
class Null extends Predicate {
//
}
class And extends Predicate {
//
}
class Or extends Predicate {
async check (entity) {
for ( const child of this.children ) {
if ( await entity.check(child) ) {
return true;
}
}
return false;
}
}
class Eq extends Predicate {
async check (entity) {
return (await entity.get(this.key)) == this.value;
}
}
class StartsWith extends Predicate {
async check (entity) {
return (await entity.get(this.key)).startsWith(this.value);
}
}
class IsNotNull extends Predicate {
async check (entity) {
return (await entity.get(this.key)) !== null;
}
}
class Like extends Predicate {
async check (entity) {
// Convert SQL LIKE pattern to RegExp
// TODO: Support escaping the pattern characters
const regex = new RegExp(this.value.replaceAll('%', '.*').replaceAll('_', '.'), 'i');
return regex.test(await entity.get(this.key));
}
}
Predicate.prototype.and = function (other) {
return new And({ children: [this, other] });
};
class PredicateUtil {
static simplify (predicate) {
if ( predicate instanceof And ) {
const simplified = [];
for ( const p of predicate.children ) {
const s = PredicateUtil.simplify(p);
if ( s instanceof And ) {
simplified.push(...s.children);
} else if ( ! (s instanceof Null) ) {
simplified.push(s);
}
}
if ( simplified.length === 0 ) {
return new Null();
}
if ( simplified.length === 1 ) {
return simplified[0];
}
return new And({ children: simplified });
}
if ( predicate instanceof Or ) {
const simplified = [];
for ( const p of predicate.children ) {
const s = PredicateUtil.simplify(p);
if ( s instanceof Or ) {
simplified.push(...s.children);
} else if ( ! (s instanceof Null) ) {
simplified.push(s);
}
}
if ( simplified.length === 0 ) {
return new Null();
}
if ( simplified.length === 1 ) {
return simplified[0];
}
return new Or({ children: simplified });
}
return predicate;
}
static write_human_readable (predicate) {
if ( predicate instanceof Eq ) {
return `${predicate.key}=${predicate.value}`;
}
if ( predicate instanceof And ) {
const parts = predicate.children.map(child =>
PredicateUtil.write_human_readable(child));
return parts.join(' and ');
}
if ( predicate instanceof Or ) {
const parts = predicate.children.map(child =>
PredicateUtil.write_human_readable(child));
return parts.join(' or ');
}
if ( predicate instanceof StartsWith ) {
return `${predicate.key} starts with "${predicate.value}"`;
}
if ( predicate instanceof IsNotNull ) {
return `${predicate.key} is not null`;
}
if ( predicate instanceof Like ) {
return `${predicate.key} like "${predicate.value}"`;
}
if ( predicate instanceof Null ) {
return '';
}
return String(predicate);
}
}
module.exports = {
Predicate,
PredicateUtil,
Null,
And,
Or,
Eq,
IsNotNull,
Like,
StartsWith,
};
================================================
FILE: src/backend/src/om/query/query.test.js
================================================
import { describe, expect, it } from 'vitest';
const {
Eq,
And,
Or,
Null,
IsNotNull,
Like,
StartsWith,
PredicateUtil,
} = require('./query');
describe('PredicateUtil', () => {
describe('write_human_readable', () => {
it('writes Eq predicate as key=value', () => {
const predicate = new Eq({ key: 'name', value: 'John' });
const result = PredicateUtil.write_human_readable(predicate);
expect(result).toBe('name=John');
});
it('writes And predicate with "and" separator', () => {
const predicate = new And({
children: [
new Eq({ key: 'name', value: 'John' }),
new Eq({ key: 'age', value: 25 }),
],
});
const result = PredicateUtil.write_human_readable(predicate);
expect(result).toBe('name=John and age=25');
});
it('writes nested And predicates', () => {
const predicate = new And({
children: [
new Eq({ key: 'name', value: 'John' }),
new Eq({ key: 'age', value: 25 }),
new Eq({ key: 'city', value: 'NYC' }),
],
});
const result = PredicateUtil.write_human_readable(predicate);
expect(result).toBe('name=John and age=25 and city=NYC');
});
it('writes Or predicate with "or" separator', () => {
const predicate = new Or({
children: [
new Eq({ key: 'status', value: 'active' }),
new Eq({ key: 'status', value: 'pending' }),
],
});
const result = PredicateUtil.write_human_readable(predicate);
expect(result).toBe('status=active or status=pending');
});
it('writes StartsWith predicate', () => {
const predicate = new StartsWith({ key: 'email', value: 'admin' });
const result = PredicateUtil.write_human_readable(predicate);
expect(result).toBe('email starts with "admin"');
});
it('writes IsNotNull predicate', () => {
const predicate = new IsNotNull({ key: 'verified_at' });
const result = PredicateUtil.write_human_readable(predicate);
expect(result).toBe('verified_at is not null');
});
it('writes Like predicate', () => {
const predicate = new Like({ key: 'name', value: '%John%' });
const result = PredicateUtil.write_human_readable(predicate);
expect(result).toBe('name like "%John%"');
});
it('writes Null predicate as empty string', () => {
const predicate = new Null();
const result = PredicateUtil.write_human_readable(predicate);
expect(result).toBe('');
});
it('writes complex nested predicates', () => {
const predicate = new And({
children: [
new Eq({ key: 'status', value: 'active' }),
new Or({
children: [
new Eq({ key: 'role', value: 'admin' }),
new Eq({ key: 'role', value: 'moderator' }),
],
}),
],
});
const result = PredicateUtil.write_human_readable(predicate);
expect(result).toBe('status=active and role=admin or role=moderator');
});
});
describe('simplify', () => {
it('simplifies nested And predicates', () => {
const predicate = new And({
children: [
new And({
children: [
new Eq({ key: 'a', value: 1 }),
new Eq({ key: 'b', value: 2 }),
],
}),
new Eq({ key: 'c', value: 3 }),
],
});
const result = PredicateUtil.simplify(predicate);
expect(result).toBeInstanceOf(And);
expect(result.children.length).toBe(3);
expect(result.children[0]).toBeInstanceOf(Eq);
expect(result.children[1]).toBeInstanceOf(Eq);
expect(result.children[2]).toBeInstanceOf(Eq);
});
it('simplifies And with single child', () => {
const predicate = new And({
children: [
new Eq({ key: 'a', value: 1 }),
],
});
const result = PredicateUtil.simplify(predicate);
expect(result).toBeInstanceOf(Eq);
expect(result.key).toBe('a');
});
it('simplifies And with Null children', () => {
const predicate = new And({
children: [
new Eq({ key: 'a', value: 1 }),
new Null(),
new Eq({ key: 'b', value: 2 }),
],
});
const result = PredicateUtil.simplify(predicate);
expect(result).toBeInstanceOf(And);
expect(result.children.length).toBe(2);
});
it('simplifies And with all Null children to Null', () => {
const predicate = new And({
children: [
new Null(),
new Null(),
],
});
const result = PredicateUtil.simplify(predicate);
expect(result).toBeInstanceOf(Null);
});
it('simplifies nested Or predicates', () => {
const predicate = new Or({
children: [
new Or({
children: [
new Eq({ key: 'a', value: 1 }),
new Eq({ key: 'b', value: 2 }),
],
}),
new Eq({ key: 'c', value: 3 }),
],
});
const result = PredicateUtil.simplify(predicate);
expect(result).toBeInstanceOf(Or);
expect(result.children.length).toBe(3);
});
it('returns non-composite predicates unchanged', () => {
const predicate = new Eq({ key: 'a', value: 1 });
const result = PredicateUtil.simplify(predicate);
expect(result).toBe(predicate);
});
});
});
describe('Predicate classes', () => {
describe('Eq', () => {
it('checks equality', async () => {
const predicate = new Eq({ key: 'status', value: 'active' });
const entity = {
get: async (key) => key === 'status' ? 'active' : null,
};
const result = await predicate.check(entity);
expect(result).toBe(true);
});
it('fails when not equal', async () => {
const predicate = new Eq({ key: 'status', value: 'active' });
const entity = {
get: async (key) => key === 'status' ? 'inactive' : null,
};
const result = await predicate.check(entity);
expect(result).toBe(false);
});
});
describe('StartsWith', () => {
it('checks if string starts with value', async () => {
const predicate = new StartsWith({ key: 'email', value: 'admin' });
const entity = {
get: async (key) => key === 'email' ? 'admin@example.com' : null,
};
const result = await predicate.check(entity);
expect(result).toBe(true);
});
it('fails when string does not start with value', async () => {
const predicate = new StartsWith({ key: 'email', value: 'admin' });
const entity = {
get: async (key) => key === 'email' ? 'user@example.com' : null,
};
const result = await predicate.check(entity);
expect(result).toBe(false);
});
});
describe('IsNotNull', () => {
it('checks if value is not null', async () => {
const predicate = new IsNotNull({ key: 'verified_at' });
const entity = {
get: async (key) => key === 'verified_at' ? '2025-01-01' : null,
};
const result = await predicate.check(entity);
expect(result).toBe(true);
});
it('fails when value is null', async () => {
const predicate = new IsNotNull({ key: 'verified_at' });
const entity = {
get: async (key) => null,
};
const result = await predicate.check(entity);
expect(result).toBe(false);
});
});
describe('Like', () => {
it('matches pattern with wildcards', async () => {
const predicate = new Like({ key: 'name', value: '%John%' });
const entity = {
get: async (key) => key === 'name' ? 'John Doe' : null,
};
const result = await predicate.check(entity);
expect(result).toBe(true);
});
it('fails when pattern does not match', async () => {
const predicate = new Like({ key: 'name', value: '%Jane%' });
const entity = {
get: async (key) => key === 'name' ? 'John Doe' : null,
};
const result = await predicate.check(entity);
expect(result).toBe(false);
});
it('is case insensitive', async () => {
const predicate = new Like({ key: 'name', value: '%john%' });
const entity = {
get: async (key) => key === 'name' ? 'JOHN DOE' : null,
};
const result = await predicate.check(entity);
expect(result).toBe(true);
});
});
describe('Or', () => {
it('returns true if any child matches', async () => {
const predicate = new Or({
children: [
new Eq({ key: 'status', value: 'active' }),
new Eq({ key: 'status', value: 'pending' }),
],
});
const entity = {
get: async (key) => key === 'status' ? 'pending' : null,
check: async (pred) => await pred.check(entity),
};
const result = await predicate.check(entity);
expect(result).toBe(true);
});
it('returns false if no children match', async () => {
const predicate = new Or({
children: [
new Eq({ key: 'status', value: 'active' }),
new Eq({ key: 'status', value: 'pending' }),
],
});
const entity = {
get: async (key) => key === 'status' ? 'inactive' : null,
check: async (pred) => await pred.check(entity),
};
const result = await predicate.check(entity);
expect(result).toBe(false);
});
});
describe('Predicate.and', () => {
it('creates an And predicate', () => {
const pred1 = new Eq({ key: 'a', value: 1 });
const pred2 = new Eq({ key: 'b', value: 2 });
const result = pred1.and(pred2);
expect(result).toBeInstanceOf(And);
expect(result.children).toEqual([pred1, pred2]);
});
});
});
================================================
FILE: src/backend/src/polyfill/to-string-higher-radix.js
================================================
/*
* 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 .
*/
/**
* Polyfill written by Chat GPT that increases the highest suppored
* radix on Number.prototype.toString from 36 to 62.
*/
(function () {
const originalToString = Number.prototype.toString;
const characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const base = characters.length; // 62
Number.prototype.toString = function (radix) {
// Use the original toString for bases 36 or lower
if ( !radix || radix <= 36 ) {
return originalToString.call(this, radix);
}
// Custom implementation for base 62
let value = this;
let result = '';
while ( value > 0 ) {
result = characters[value % base] + result;
value = Math.floor(value / base);
}
return result || '0';
};
})();
================================================
FILE: src/backend/src/public/assets/css/admin.css
================================================
h1{
border-bottom: 2px solid #CCC;
padding-bottom: 10px;
margin-bottom: 30px;
font-size: 25px;
}
h1 .bi-caret-right-fill{
color: rgb(210, 210, 210);
font-size: 25px;
}
h1 a, h1 a:visited{
color: #000;
text-decoration: none;
}
h1 a:hover{
text-decoration: underline;
}
/* ------------------------------------ */
/* Admin
/* ------------------------------------ */
.admin-sidebar{
height: 100%;
width: 260px;
position: fixed;
top: 0;
left: 0;
background-color: #eee;
overflow-x: hidden;
padding-top: 20px;
}
.admin-main{
margin-left: 270px;
padding: 0px 10px;
overflow: hidden;
}
.sidebar-item{
display: block;
padding: 10px;
margin:10px;
text-decoration: none;
color: #000;
border-radius: 5px;
background-color: #dee1e8;
}
.sidebar-item.active{
background-color: #a2abba;
color:white;
}
td{
white-space: nowrap;
}
.count{
float:right;
font-size: 13px;
font-weight: bold;
line-height: 25px;
display: block;
}
================================================
FILE: src/backend/src/public/assets/css/style.css
================================================
html, body {
font-family: 'Roboto', HelveticaNeue, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#html-login, #body-login, #html-signup, #body-signup, #html-password-recovery, #body-password-recovery,
#html-set-new-password, #body-set-new-password {
height: 100%;
}
#html-login h1, #html-signup h1, #html-password-recovery h1, #html-set-new-password h1{
color: #5a667a;
text-shadow: 1px 1px white;
font-size:23px;
}
#body-legal{
margin-top: 40px;
margin-bottom: 100px;
}
#body-legal h1 {
margin-top: 50px;
text-align: center;
text-transform: uppercase;
font-size: 35px;
}
#body-legal h2{
font-size: 25px;;
margin-top: 50px;
}
#body-legal h3{
font-size:20px;
}
#body-legal h4 {
margin-top: 40px;
margin-bottom: 10px;
}
#body-legal ol > h3{
font-size: 18px;
margin-top: 20px;
margin-left: -25px;
}
#body-legal ul li{
margin-bottom: 10px;
}
.tos-li-head {
font-weight: bold;
display: block;
margin-bottom: 10px;
margin-top: 30px;
}
#body-login, #body-signup, #body-password-recovery, #body-set-new-password {
display: flex;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
background-color: #f5f5f5;
text-align: center;
}
#body-index {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.form-signin {
width: 100%;
max-width: 330px;
padding: 15px;
margin: auto;
}
.form-signin .form-floating:focus-within {
z-index: 2;
}
#login-error-msg, .error-msg, .error {
display: none;
color: red;
border: 1px solid red;
border-radius: 4px;
padding: 9px;
margin-bottom: 15px;
text-align: center;
font-size: 13px;
}
.error{
display: block;
}
.success-msg{
display: none;
color: green;
border: 1px solid green;
border-radius: 4px;
padding: 9px;
margin-bottom: 15px;
text-align: center;
font-size: 13px;
}
@media (min-width: 992px) {
.rounded-lg-3 {
border-radius: .3rem;
}
}
#signup-error-msg {
display: none;
color: red;
border: 1px solid red;
border-radius: 4px;
padding: 9px;
margin-bottom: 15px;
text-align: center;
font-size: 13px;
}
.logo {
border-radius: 3px;
}
.signup-c2a, .login-c2a {
color: #656f7a;
text-align: center;
margin: 0;
font-size: 14px;
text-shadow: 1px 1px #ffffffe3;
}
.signup-c2a a, .login-c2a a, .pass-reco-link {
text-decoration: none;
}
.signup-c2a a:hover, .login-c2a a:hover, .pass-reco-link:hover {
text-decoration: underline;
}
.pass-reco-link{
font-size:14px;
}
.c2a-wrapper {
display: block;
text-align: center;
font-size: 18px;
padding-top: 15px;
padding-bottom: 15px;
border: 1px solid #bfc3cb;
color: #949AA8;
margin-bottom: 0;
border-radius: 6px;
margin-top: 20px;
}
.social-media-icon {
width: 30px;
float: right;
margin-left: 20px;
}
.hero-browser {
margin: 0 auto;
background-color: #c5c7cd;
overflow: hidden;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
box-shadow: 0 0 10px #8b8b8b7a;
}
.hero-browser-buttons {
border-radius: 100%;
width: 10px;
height: 9px;
background-color: #EEE;
float: left;
margin-top: 11px;
margin-right: 8px;
}
.hero-browser-url {
background-color: white;
width: 100%;
text-align: left;
border-radius: 20px;
padding-left: 20px;
margin-left: 15px;
padding: 5px 5px 5px 20px;
font-size: 16px;
font-weight: bold;
color: #3b5f6c;
}
.hero-browser-url-lock {
width: 15px;
height: 15px;
opacity: 0.2;
margin-top: -4px;
margin-right: 10px;
}
#p102xyzname {
display: none;
}
.feature-icon {
width: 50px;
margin-bottom: 20px;
}
.pass-recovery-email-sent{
display:none;
border: 1px solid #00c300;
padding: 20px 15px;
border-radius: 3px;
color: darkgreen;
background: #e5ffe5;
margin-bottom: 20px;
}
.green-1{
background-color: rgb(227, 255, 236);
}
.green-2{
background-color: rgb(139, 228, 168);
}
.green-3{
background-color: rgb(49, 202, 97);
}
================================================
FILE: src/backend/src/public/assets/js/app.js
================================================
$(document).ready(function () {
if ( page === 'login' )
{
$('#email_or_username').focus();
}
else if ( page === 'password-recovery' )
{
$('#email_or_username').focus();
}
else if ( page === 'set-new-password' )
{
$('#password').focus();
}
});
window.is_email = (email) => {
const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(email).toLowerCase());
};
$('#login-submit-btn').on('click', function () {
const email_username = $('#email_or_username').val();
const password = $('#password').val();
let data;
if ( is_email(email_username) ) {
data = JSON.stringify({
email: email_username,
password: password,
});
} else {
data = JSON.stringify({
username: email_username,
password: password,
});
}
$('#login-error-msg').hide();
$.ajax({
url: '/login',
type: 'POST',
async: false,
contentType: 'application/json',
data: data,
success: function (data) {
localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_username', data.user.username);
window.location.replace('/');
},
error: function (err) {
$('#login-error-msg').html(err.responseText);
$('#login-error-msg').fadeIn();
},
});
});
$('#pass-recovery-submit-btn').on('click', function (e) {
const email_username = $('#email_or_username').val();
let data;
if ( is_email(email_username) ) {
data = JSON.stringify({
email: email_username,
});
} else {
data = JSON.stringify({
username: email_username,
});
}
$('#login-error-msg').hide();
$.ajax({
url: '/send-pass-recovery-email',
type: 'POST',
async: false,
contentType: 'application/json',
data: data,
success: function (data) {
$('#email_or_username').val('');
$('.pass-recovery-email-sent').html(data);
$('.pass-recovery-email-sent').fadeIn();
},
error: function (err) {
$('#login-error-msg').html(err.responseText);
$('#login-error-msg').fadeIn();
},
});
});
$('.signup-btn').on('click', function (e) {
let urlquery = new URLSearchParams(window.location.search);
let tok;
if ( urlquery.has('tok') )
{
tok = urlquery.get('tok');
}
// todo do some basic validation client-side
//Username
let username = $('#username').val();
//Email
let email = $('#email').val();
//Password
let password = $('#password').val();
//xyzname
let p102xyzname = $('#p102xyzname').val();
// disable 'Create Account' button
$('.signup-btn').prop('disabled', true);
$.ajax({
url: '/signup',
type: 'POST',
async: true,
contentType: 'application/json',
data: JSON.stringify({
username: username,
email: email,
password: password,
uuid: tok,
p102xyzname: p102xyzname,
}),
success: function (data) {
localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_username', data.user.username);
window.location.replace('/');
},
error: function (err) {
$('#signup-error-msg').html(err.responseText);
$('#signup-error-msg').fadeIn();
// re-enable 'Create Account' button
$('.signup-btn').prop('disabled', false);
},
});
});
$('.signup-form, .login-form, .pass-recovery-form, .set-password-form').on('submit', function (e) {
e.preventDefault();
e.stopPropagation();
return false;
});
$('#set-new-pass-submit-btn').on('click', function (e) {
// todo do some basic validation client-side
//Password
let password = $('#password').val();
let token = $('#token').val();
let user_id = $('#user_id').val();
// disable submit button
$('#set-new-pass-submit-btn').prop('disabled', true);
$.ajax({
url: '/set-pass-using-token',
type: 'POST',
async: true,
contentType: 'application/json',
data: JSON.stringify({
password: password,
token: token,
user_id: user_id,
}),
success: function (data) {
$('.success-msg').html('Password updated. Log in .');
$('.error-msg').hide();
$('.success-msg').fadeIn();
$('#password').val('');
},
error: function (err) {
$('.error-msg').html(err.responseText);
$('.error-msg').fadeIn();
// re-enable 'Create Account' button
$('#set-new-pass-submit-btn').prop('disabled', false);
},
});
});
================================================
FILE: src/backend/src/routers/_default.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const config = require('../config');
const router = express.Router();
const _path = require('path');
const _fs = require('fs');
const { Context } = require('../util/context');
const { DB_READ } = require('../services/database/consts');
const { PathBuilder } = require('../util/pathutil.js');
let auth_user;
// Helper function to safely handle metadata parsing
const parseMetadata = (metadata) => {
try {
// If metadata is null or undefined, return empty object
if ( ! metadata ) {
return {};
}
// If metadata is already an object, return it
if ( typeof metadata === 'object' && !Array.isArray(metadata) ) {
return metadata;
}
// If metadata is a string, try to parse it
if ( typeof metadata === 'string' ) {
return JSON.parse(metadata);
}
// If we get here, metadata is of an unexpected type
console.warn('Unexpected metadata type:', typeof metadata);
return {};
} catch ( error ) {
console.error('Error parsing metadata:', error);
return {};
}
};
// -----------------------------------------------------------------------//
// All other requests
// -----------------------------------------------------------------------//
router.all('*', async function (req, res, next) {
const subdomain = req.hostname.slice(0, -1 * (config.domain.length + 1));
let path = req.params[0] ? req.params[0] : 'index.html';
// --------------------------------------
// API
// --------------------------------------
if ( subdomain === 'api' ) {
return next();
}
// --------------------------------------
// /puter.js/v1 must be accessible globally regardless of subdomain
// --------------------------------------
else if ( path === '/puter.js/v1' || path === '/puter.js/v1/' ) {
return res.sendFile(_path.join(__dirname, config.defaultjs_asset_path, 'puter.js/v1.js'), function (err) {
if ( err && err.statusCode ) {
return res.status(err.statusCode).send('Error /puter.js');
}
});
}
else if ( path === '/puter.js/v2' || path === '/puter.js/v2/' ) {
return res.sendFile(_path.join(__dirname, config.defaultjs_asset_path, 'puter.js/v2.js'), function (err) {
if ( err && err.statusCode ) {
return res.status(err.statusCode).send('Error /puter.js');
}
});
}
// --------------------------------------
// https://js.[domain]/v1/
// --------------------------------------
else if ( subdomain === 'js' ) {
if ( path === '/v1' || path === '/v1/' ) {
return res.sendFile(_path.join(__dirname, config.defaultjs_asset_path, 'puter.js/v1.js'), function (err) {
if ( err && err.statusCode ) {
return res.status(err.statusCode).send('Error /puter.js');
}
});
}
if ( path === '/v2' || path === '/v2/' ) {
return res.sendFile(_path.join(__dirname, config.defaultjs_asset_path, 'puter.js/v2.js'), function (err) {
if ( err && err.statusCode ) {
return res.status(err.statusCode).send('Error /puter.js');
}
});
}
if ( path === '/putility/v1' ) {
return res.sendFile(_path.join(__dirname, config.defaultjs_asset_path, 'putility.js/v1.js'), function (err) {
if ( err && err.statusCode ) {
return res.status(err.statusCode).send('Error /putility.js');
}
});
}
}
const db = Context.get('services').get('database').get(DB_READ, 'default');
const authService = Context.get('services').get('auth');
// --------------------------------------
// POST to login/signup/logout
// --------------------------------------
if ( subdomain === '' && req.method === 'POST' &&
(
path === '/login' ||
path === '/signup' ||
path === '/logout' ||
path === '/send-pass-recovery-email' ||
path === '/set-pass-using-token'
)
) {
return next();
}
// --------------------------------------
// No subdomain: either GUI or landing pages
// --------------------------------------
else if ( subdomain === '' ) {
// auth
const { jwt_auth, get_app, invalidate_cached_user } = require('../helpers');
let authed = false;
try {
try {
auth_user = await jwt_auth(req, authService);
auth_user = auth_user.user;
authed = true;
} catch (e) {
authed = false;
}
}
catch (e) {
authed = false;
}
if ( path === '/robots.txt' ) {
res.set('Content-Type', 'text/plain');
let r = '';
r += 'User-agent: AhrefsBot\nDisallow:/\n\n';
r += 'User-agent: BLEXBot\nDisallow: /\n\n';
r += 'User-agent: DotBot\nDisallow: /\n\n';
r += 'User-agent: ia_archiver\nDisallow: /\n\n';
r += 'User-agent: MJ12bot\nDisallow: /\n\n';
r += 'User-agent: SearchmetricsBot\nDisallow: /\n\n';
r += 'User-agent: SemrushBot\nDisallow: /\n\n';
// sitemap
r += `\nSitemap: ${config.protocol}://${config.domain}/sitemap.xml\n`;
return res.send(r);
}
else if ( path === '/sitemap.xml' ) {
let h = '';
h += '';
h += '';
// docs
h += '';
h += `${config.protocol}://docs.${config.domain}/ `;
h += ' ';
// apps
// TODO: use service for app discovery
let apps = await db.read('SELECT * FROM apps WHERE approved_for_listing = 1');
if ( apps.length > 0 ) {
for ( let i = 0; i < apps.length; i++ ) {
const app = apps[i];
h += '';
h += `${config.protocol}://${config.domain}/app/${app.name} `;
h += ' ';
}
}
h += ' ';
res.set('Content-Type', 'application/xml');
return res.send(h);
}
else if ( path === '/unsubscribe' ) {
let h = '';
if ( req.query.user_uuid === undefined )
{
h += 'user_uuid is required
';
}
else {
// modules
const { get_user } = require('../helpers');
// get user
const user = await get_user({ uuid: req.query.user_uuid });
// more validation
if ( ! user )
{
h += 'User not found.
';
}
else if ( user.unsubscribed === 1 )
{
h += 'You are already unsubscribed.
';
}
// mark user as confirmed
else {
await db.write(
'UPDATE `user` SET `unsubscribed` = 1 WHERE id = ?',
[user.id],
);
invalidate_cached_user(user);
// return results
h += 'Your have successfully unsubscribed from all emails.
';
}
}
h += '';
res.send(h);
}
else if ( path === '/confirm-email-by-token' ) {
let h = '';
if ( req.query.user_uuid === undefined )
{
h += 'user_uuid is required
';
}
else if ( req.query.token === undefined )
{
h += 'token is required
';
}
else {
// modules
const { get_user } = require('../helpers');
// get user
const user = await get_user({ uuid: req.query.user_uuid, force: true });
// more validation
if ( user === undefined || user === null || user === false )
{
h += 'user not found.
';
}
else if ( user.email_confirmed === 1 )
{
h += 'Email already confirmed.
';
}
else if ( user.email_confirm_token !== req.query.token )
{
h += 'invalid token.
';
}
// mark user as confirmed
else {
// This IIFE is here to return early on conditions, and
// avoid further nested branching. This is a temporary
// solution; next time this code should be refactored.
await (async () => {
const svc_cleanEmail = req.services.get('clean-email');
const clean_email = svc_cleanEmail.clean(user.email);
// If other users have the same CONFIRMED email, display an error
const maybe_rows = await db.read(
`SELECT EXISTS(
SELECT 1 FROM user WHERE (email=? OR clean_email=?)
AND email_confirmed=1
AND password IS NOT NULL
) AS email_exists`,
[user.email, clean_email],
);
if ( maybe_rows[0]?.email_exists ) {
// TODO: maybe display the username of that account
h += '' +
'This email was confirmed on a different account.
';
return;
}
// If other users have the same unconfirmed email, revoke it
await db.write(
'UPDATE `user` SET `unconfirmed_change_email` = NULL, `change_email_confirm_token` = NULL WHERE `unconfirmed_change_email` = ?',
[user.email],
);
// update user
await db.write(
'UPDATE `user` SET `email_confirmed` = 1, `requires_email_confirmation` = 0 WHERE id = ?',
[user.id],
);
invalidate_cached_user(user);
// send realtime success msg to client
const svc_socketio = req.services.get('socketio');
svc_socketio.send({ room: user.id }, 'user.email_confirmed', {});
// return results
h += 'Your email has been successfully confirmed.
';
const svc_event = req.services.get('event');
svc_event.emit('user.email-confirmed', {
user_uid: user.uuid,
email: user.email,
});
})();
}
}
h += '';
res.send(h);
}
// ------------------------
// /assets/
// ------------------------
else if ( path.startsWith('/assets/') ) {
path = PathBuilder.resolve(path);
return res.sendFile(path, { root: `${__dirname }../../public` }, function (err) {
if ( err && err.statusCode ) {
return res.status(err.statusCode).send('Error /public/');
}
});
}
// ------------------------
// GUI
// ------------------------
else {
let app;
let canonical_url = config.origin + path;
let app_name, app_title, app_description, app_icon, app_social_media_image;
let launch_options = {
on_initialized: [],
};
// default title
app_title = config.title;
// /action/
if ( path.startsWith('/action/') || path.startsWith('/@') ) {
path = '/';
}
// /settings
else if ( path.startsWith('/settings') ) {
path = '/';
}
// /dashboard
else if ( path === '/dashboard' || path === '/dashboard/' ) {
path = '/';
}
// /app/
else if ( path.startsWith('/app/') ) {
app_name = path.replace('/app/', '');
app = await get_app({
follow_old_names: true,
name: app_name,
});
if ( app ) {
// parse app metadata if available
app.metadata = parseMetadata(app.metadata);
// set app attributes to be passed to the homepage service
app_title = app.title;
app_description = app.description;
app_icon = app.icon;
app_social_media_image = app.metadata?.social_image;
}
// 404 - Not found!
else if ( app_name ) {
app_title = app_name.charAt(0).toUpperCase() + app_name.slice(1);
res.status(404);
}
path = '/';
}
else if ( path.startsWith('/show/') ) {
const filepath = path.slice('/show'.length);
launch_options.on_initialized.push({
$: 'window-call',
fn_name: 'launch_app',
args: [{
name: 'explorer',
path: filepath,
}],
});
path = '/';
}
// index.js
if ( path === '/' ) {
const svc_puterHomepage = Context.get('services').get('puter-homepage');
return svc_puterHomepage.send({ req, res }, {
title: app_title,
description: app_description || config.short_description,
short_description: app_description || config.short_description,
social_media_image: app_social_media_image || config.social_media_image,
company: 'Puter Technologies Inc.',
canonical_url: canonical_url,
icon: app_icon,
app: app,
}, launch_options);
}
// /dist/...
else if ( path.startsWith('/dist/') || path.startsWith('/src/') ) {
path = PathBuilder.resolve(path);
return res.sendFile(path, { root: config.assets.gui }, function (err) {
if ( err && err.statusCode ) {
return res.status(err.statusCode).send('Error /gui/dist/');
}
});
}
// All other paths
else {
path = PathBuilder.resolve(path);
return res.sendFile(path, { root: _path.join(config.assets.gui, 'src') }, function (err) {
if ( err && err.statusCode ) {
return res.status(err.statusCode).send('Error /gui/');
}
});
}
}
}
// --------------------------------------
// Native Apps
// --------------------------------------
else if ( subdomain === 'viewer' || subdomain === 'editor' || subdomain === 'about' || subdomain === 'docs' ||
subdomain === 'player' || subdomain === 'pdf' || subdomain === 'code' || subdomain === 'markus' ||
subdomain === 'draw' || subdomain === 'camera' || subdomain === 'recorder' ||
subdomain === 'dev-center' || subdomain === 'developer' ) {
let root = PathBuilder
.add(__dirname)
.add(config.defaultjs_asset_path, { allow_traversal: true })
.add('apps').add(subdomain)
.build();
const has_dist = ['docs', 'developer'];
if ( has_dist.includes(subdomain) ) {
root += '/dist';
}
root = _path.normalize(root);
path = _path.normalize(path);
const real_path = _path.normalize(_path.join(root, path));
// Determine if the path is a directory
// (necessary because otherwise res.sendFile() will HANG!)
try {
const is_dir = (await _fs.promises.stat(real_path)).isDirectory();
if ( is_dir && !path.endsWith('/') ) {
// Redirect to directory (use 307 to avoid browser caching)
path += '/';
let redirect_url = `${req.protocol }://${ req.get('host') }${path}`;
// We need to add the query string to the redirect URL
if ( req.query ) {
const old_url = `${req.protocol }://${ req.get('host') }${req.originalUrl}`;
redirect_url += new URL(old_url).search;
}
return res.redirect(307, redirect_url);
}
} catch (e) {
console.error(e);
return res.status(404).send('Not found');
}
try {
return res.sendFile(path, { root }, function (err) {
if ( err && err.statusCode ) {
return res.status(err.statusCode).send('Error /apps/');
}
});
} catch (e) {
console.error('error from sendFile', e);
return res.status(e.statusCode).send('Error /apps/');
}
}
// --------------------------------------
// WWW, redirect to root domain
// --------------------------------------
else if ( subdomain === 'www' ) {
return res.redirect(config.origin);
}
//------------------------------------------
// User-defined subdomains: *.puter.com
// redirect to static hosting domain *.puter.site
//------------------------------------------
else {
if ( req.get('host').toLowerCase().endsWith(config.domain) ) {
return res.redirect(302, `${req.protocol }://${ req.get('host').replace(config.domain, config.static_hosting_domain) }${req.originalUrl}`);
// replace hostname with static hosting domain and redirect to the same path
}
}
});
module.exports.catchAllRouter = router;
================================================
FILE: src/backend/src/routers/apps.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
const auth = require('../middleware/auth.js');
const config = require('../config');
const { get_apps, app_name_exists } = require('../helpers');
const { DB_READ } = require('../services/database/consts.js');
const subdomain = require('../middleware/subdomain.js');
let privateLaunchAccessModulePromise;
const getPrivateLaunchAccessModule = async () => {
if ( ! privateLaunchAccessModulePromise ) {
privateLaunchAccessModulePromise = import('../modules/apps/privateLaunchAccess.js');
}
return privateLaunchAccessModulePromise;
};
// -----------------------------------------------------------------------//
// GET /apps
// -----------------------------------------------------------------------//
router.get(
'/apps',
subdomain('api'),
auth,
express.json({ limit: '50mb' }),
async (req, res) => {
// /!\ open brace on end of previous line
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
const db = req.services.get('database').get(DB_READ, 'apps');
let apps_res = await db.read(
'SELECT * FROM apps WHERE owner_user_id = ? ORDER BY timestamp DESC',
[req.user.id],
);
const svc_appInformation = req.services.get('app-information');
let apps = [];
if ( apps_res.length > 0 ) {
for ( let i = 0; i < apps_res.length; i++ ) {
// filetype associations
let ftassocs = await db.read(
'SELECT * FROM app_filetype_association WHERE app_id = ?',
[apps_res[i].id],
);
let filetype_associations = [];
if ( ftassocs.length > 0 ) {
ftassocs.forEach(ftassoc => {
filetype_associations.push(ftassoc.type);
});
}
const stats = await svc_appInformation.get_stats(apps_res[i].uid);
apps.push({
uid: apps_res[i].uid,
name: apps_res[i].name,
description: apps_res[i].description,
title: apps_res[i].title,
icon: apps_res[i].icon,
index_url: apps_res[i].index_url,
godmode: apps_res[i].godmode,
background: apps_res[i].background,
maximize_on_start: apps_res[i].maximize_on_start,
filetype_associations: filetype_associations,
...stats,
approved_for_incentive_program: apps_res[i].approved_for_incentive_program,
created_at: apps_res[i].timestamp,
});
}
}
return res.send(apps);
},
);
// -----------------------------------------------------------------------//
// GET /apps/nameAvailable?name=
// -----------------------------------------------------------------------//
router.get(
'/apps/nameAvailable',
subdomain('api'),
auth,
express.json({ limit: '50mb' }),
async (req, res) => {
const name = req.query.name;
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
if ( typeof name !== 'string' ) {
return res.status(400).send({
code: 'invalid_request',
message: 'name query parameter must be a string',
});
}
if ( name.length === 0 ) {
return res.status(400).send({
code: 'invalid_request',
message: 'name query parameter is required',
});
}
if ( name.length > config.app_name_max_length || !config.app_name_regex.test(name) ) {
return res.status(400).send({
code: 'invalid_request',
message: `name must match app naming rules (max length: ${config.app_name_max_length})`,
});
}
const exists = !!(await app_name_exists(name));
return res.send({
name,
available: !exists,
});
},
);
// -----------------------------------------------------------------------//
// GET /apps/:name(s)
// -----------------------------------------------------------------------//
router.get(
'/apps/:name',
subdomain('api'),
auth,
express.json({ limit: '50mb' }),
async (req, res, next) => {
// /!\ open brace on end of previous line
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
const {
getActorUserUid,
resolvePrivateLaunchAccess,
} = await getPrivateLaunchAccessModule();
let app_names = req.params.name.split('|');
const apps = await get_apps(app_names.map(name => ({ name })));
const actorUserUid = getActorUserUid(req.actor) || req.user?.uuid || null;
const privateAccessDecisions = await Promise.all(apps.map(app => {
if ( ! app ) return Promise.resolve(null);
return resolvePrivateLaunchAccess({
app,
services: req.services,
userUid: actorUserUid,
source: 'appsRoute',
args: req.query ?? {},
});
}));
const final_obj = apps.map((app, index) => {
if ( ! app ) return null;
return {
uuid: app.uid,
name: app.name,
title: app.title,
icon: app.icon,
godmode: app.godmode,
background: app.background,
maximize_on_start: app.maximize_on_start,
index_url: app.index_url,
privateAccess: privateAccessDecisions[index] ?? {
hasAccess: true,
checkedBy: 'core/apps-route-default',
},
};
}).filter(Boolean);
return res.send(final_obj);
},
);
module.exports = router;
================================================
FILE: src/backend/src/routers/auth/app-uid-from-origin.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { Context } = require('../../util/context');
module.exports = eggspress('/auth/app-uid-from-origin', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST', 'GET'],
}, async (req, res, next) => {
const x = Context.get();
const svc_auth = x.get('services').get('auth');
const origin = req.body.origin || req.query.origin;
if ( ! origin ) {
throw APIError.create('field_missing', null, { key: 'origin' });
}
res.json({
uid: await svc_auth.app_uid_from_origin(origin),
});
});
================================================
FILE: src/backend/src/routers/auth/check-app-acl.endpoint.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const FSNodeParam = require('../../api/filesystem/FSNodeParam');
const StringParam = require('../../api/filesystem/StringParam');
const { get_app } = require('../../helpers');
const configurable_auth = require('../../middleware/configurable_auth');
const { Eq, Or } = require('../../om/query/query');
const { UserActorType, Actor, AppUnderUserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
module.exports = {
route: '/check-app-acl',
methods: ['POST'],
// TODO: "alias" should be part of parameters somehow
alias: {
uid: 'subject',
path: 'subject',
},
parameters: {
subject: new FSNodeParam('subject'),
mode: new StringParam('mode', { optional: true }),
// TODO: There should be an "AppParam", but it feels wrong to include
// so many concerns into `src/api/filesystem` like that. This needs to
// be de-coupled somehow first.
app: new StringParam('app'),
},
mw: [configurable_auth()],
handler: async (req, res) => {
const context = Context.get();
const actor = req.actor;
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
const subject = req.values.subject;
const svc_acl = context.get('services').get('acl');
if ( ! await svc_acl.check(actor, subject, 'see') ) {
throw APIError.create('subject_does_not_exist');
}
const es_app = context.get('services').get('es:app');
const app = await es_app.read({
predicate: new Or({
children: [
new Eq({ key: 'uid', value: req.values.app }),
new Eq({ key: 'name', value: req.values.app }),
],
}),
});
if ( ! app ) {
throw APIError.create('app_does_not_exist', null, {
identifier: req.values.app,
});
}
const app_actor = new Actor({
type: new AppUnderUserActorType({
user: actor.type.user,
// TODO: get legacy app object from entity instead of fetching again
app: await get_app({ uid: await app.get('uid') }),
}),
});
res.json({
allowed: await svc_acl.check(app_actor, subject,
// If mode is not specified, check the HIGHEST mode, because this
// will grant the LEAST cases
req.values.mode ?? svc_acl.get_highest_mode()),
});
},
};
================================================
FILE: src/backend/src/routers/auth/check-app.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { get_app } = require('../../helpers');
const { UserActorType, Actor, AppUnderUserActorType } = require('../../services/auth/Actor');
const { PermissionUtil } = require('../../services/auth/permissionUtils.mjs');
const { Context } = require('../../util/context');
module.exports = eggspress('/auth/check-app', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_auth = x.get('services').get('auth');
const svc_permission = x.get('services').get('permission');
// Only users can get user-app tokens
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
if ( req.body.app_uid === undefined && req.body.origin === undefined ) {
throw APIError.create('field_missing', null, {
// TODO: standardize a way to provide multiple options
key: 'app_uid or origin',
});
}
const app_uid = req.body.app_uid ??
await svc_auth.app_uid_from_origin(req.body.origin);
const app = await get_app({ uid: app_uid });
if ( ! app ) {
throw APIError.create('app_does_not_exist', null, {
identifier: app_uid,
});
}
const user = actor.type.user;
const app_actor = new Actor({
user_uid: user.uuid,
app_uid,
type: new AppUnderUserActorType({
user,
app,
}),
});
const reading = await svc_permission.scan(app_actor, 'flag:app-is-authenticated');
const options = PermissionUtil.reading_to_options(reading);
const authenticated = options.length > 0;
let token;
if ( authenticated ) token = await svc_auth.get_user_app_token(app_uid);
res.json({
...(token ? { token } : {}),
app_uid: app_uid ||
await svc_auth.app_uid_from_origin(req.body.origin),
authenticated,
});
});
================================================
FILE: src/backend/src/routers/auth/check-permissions.js
================================================
/*
* 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 .
*/
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const APIError = require('../../api/APIError');
module.exports = eggspress('/auth/check-permissions', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, _next) => {
const context = Context.get();
/** @type {import('../../services/auth/PermissionService').PermissionService} */
const permissionService = context.get('services').get('permission');
const permsToCheck = req.body.permissions;
const actor = context.get('actor');
const permEntryPromises = [...new Set(permsToCheck)].map(async (perm) => {
try {
return [perm, permissionService.check(actor, perm)];
} catch {
return [perm, false];
}
});
const permEntries = Promise.all(permEntryPromises);
res.json({ permissions: Object.fromEntries(await permEntries) });
});
================================================
FILE: src/backend/src/routers/auth/configure-2fa.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { get_user, invalidate_cached_user_by_id } = require('../../helpers');
const { UserActorType } = require('../../services/auth/Actor');
const { DB_WRITE } = require('../../services/database/consts');
const { Context } = require('../../util/context');
module.exports = eggspress('/auth/configure-2fa/:action', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res) => {
const action = req.params.action;
const x = Context.get();
// Only users can configure 2FA
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
const actions = {};
const db = await x.get('services').get('database').get(DB_WRITE, '2fa');
actions.setup = async () => {
const user = await get_user({ id: req.user.id, force: true });
if ( user.otp_enabled ) {
throw APIError.create('2fa_already_enabled');
}
const svc_otp = x.get('services').get('otp');
// generate secret
const result = svc_otp.create_secret(user.username);
// generate recovery codes
result.codes = [];
for ( let i = 0; i < 10; i++ ) {
result.codes.push(svc_otp.create_recovery_code());
}
const hashed_recovery_codes = result.codes.map(code => {
const crypto = require('crypto');
const hash = crypto
.createHash('sha256')
.update(code)
.digest('base64')
// We're truncating the hash for easier storage, so we have 128
// bits of entropy instead of 256. This is plenty for recovery
// codes, which have only 48 bits of entropy to begin with.
.slice(0, 22);
return hash;
});
// update user
await db.write(
'UPDATE user SET otp_secret = ?, otp_recovery_codes = ? WHERE uuid = ?',
[result.secret, hashed_recovery_codes.join(','), user.uuid],
);
req.user.otp_secret = result.secret;
req.user.otp_recovery_codes = hashed_recovery_codes.join(',');
user.otp_secret = result.secret;
user.otp_recovery_codes = hashed_recovery_codes.join(',');
invalidate_cached_user_by_id(req.user.id);
return result;
};
// IMPORTANT: only use to verify the user's 2FA setup;
// this should never be used to verify the user's 2FA code
// for authentication purposes.
actions.test = async () => {
const user = await get_user({ id: req.user.id, force: true });
const svc_otp = x.get('services').get('otp');
const code = req.body.code;
const ok = svc_otp.verify(user.username, user.otp_secret, code);
return { ok };
};
actions.enable = async () => {
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('enable-2fa') ) {
return res.status(429).send('Too many requests.');
}
const user = await get_user({ id: req.user.id, force: true });
if ( ! user.email_confirmed ) {
throw APIError.create('email_must_be_confirmed', null, {
action: 'enable 2FA',
});
}
// Verify that 2FA isn't already enabled
if ( user.otp_enabled ) {
throw APIError.create('2fa_already_enabled');
}
// Verify that TOTP secret was set (configuration step not skipped)
if ( ! user.otp_secret ) {
throw APIError.create('2fa_not_configured');
}
await db.write(
'UPDATE user SET otp_enabled = 1 WHERE uuid = ?',
[user.uuid],
);
invalidate_cached_user_by_id(req.user.id);
// update cached user
req.user.otp_enabled = 1;
const svc_email = req.services.get('email');
await svc_email.send_email({ email: user.email }, 'enabled_2fa', {
username: user.username,
});
return {};
};
if ( ! actions[action] ) {
throw APIError.create('invalid_action', null, { action });
}
const result = await actions[action]();
res.json(result);
});
================================================
FILE: src/backend/src/routers/auth/create-access-token.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { Context } = require('../../util/context');
module.exports = eggspress('/auth/create-access-token', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_auth = x.get('services').get('auth');
const permissions = req.body.permissions || [];
if ( permissions.length === 0 ) {
throw APIError.create('field_missing', null, { key: 'permissions' });
}
for ( let i = 0 ; i < permissions.length ; i++ ) {
let perm = permissions[i];
if ( typeof perm === 'string' ) {
perm = permissions[i] = [perm];
}
if ( ! Array.isArray(perm) ) {
throw APIError.create('field_invalid', null, { key: 'permissions' });
}
if ( perm.length === 0 || perm.length > 2 ) {
throw APIError.create('field_invalid', null, { key: 'permissions' });
}
if ( typeof perm[0] !== 'string' ) {
throw APIError.create('field_invalid', null, { key: 'permissions' });
}
if ( perm.length === 2 && typeof perm[1] !== 'object' ) {
throw APIError.create('field_invalid', null, { key: 'permissions' });
}
}
const actor = Context.get('actor');
const options = {
...(req.body.expiresIn ? { expiresIn: `${ req.body.expiresIn}` } : {}),
};
const token = await svc_auth.create_access_token(actor, permissions, options);
res.json({ token });
});
================================================
FILE: src/backend/src/routers/auth/get-user-app-token.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { LLMkdir } = require('../../filesystem/ll_operations/ll_mkdir');
const { NodeUIDSelector, NodePathSelector } = require('../../filesystem/node/selectors');
const { NodeChildSelector } = require('../../filesystem/node/selectors');
const { get_app } = require('../../helpers');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
module.exports = eggspress('/auth/get-user-app-token', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_auth = x.get('services').get('auth');
// Only users can get user-app tokens
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
if ( req.body.app_uid === undefined && req.body.origin === undefined ) {
throw APIError.create('field_missing', null, {
// TODO: standardize a way to provide multiple options
key: 'app_uid or origin',
});
}
const token = ( req.body.app_uid !== undefined )
? await svc_auth.get_user_app_token(req.body.app_uid)
: await svc_auth.get_user_app_token_from_origin(req.body.origin)
;
const app_uid = req.body.app_uid ??
await svc_auth.app_uid_from_origin(req.body.origin);
const app = await get_app({ uid: app_uid });
if ( ! app ) {
throw APIError.create('app_does_not_exist', null, {
identifier: app_uid,
});
}
const svc_fs = x.get('services').get('filesystem');
const appdata_dir_sel = actor.type.user.appdata_uuid
? new NodeUIDSelector(actor.type.user.appdata_uuid)
: new NodePathSelector(`/${actor.type.user.username}/AppData`);
const appdata_app_dir_node = await svc_fs.node(new NodeChildSelector(appdata_dir_sel,
app_uid));
if ( ! await appdata_app_dir_node.exists() ) {
const ll_mkdir = new LLMkdir();
await ll_mkdir.run({
thumbnail: app.icon,
parent: await svc_fs.node(appdata_dir_sel),
name: app_uid,
actor: actor,
});
}
const svc_permission = x.get('services').get('permission');
svc_permission.grant_user_app_permission(actor, app_uid, 'flag:app-is-authenticated');
res.json({
token,
app_uid: app_uid ||
await svc_auth.app_uid_from_origin(req.body.origin),
});
});
================================================
FILE: src/backend/src/routers/auth/grant-dev-app.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const { validate_fields } = require('../../util/validutil');
module.exports = eggspress('/auth/grant-dev-app', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_permission = x.get('services').get('permission');
// Only users can grant user-app permissions
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
if ( req.body.origin ) {
const svc_auth = x.get('services').get('auth');
req.body.app_uid = await svc_auth.app_uid_from_origin(req.body.origin);
}
validate_fields({
app_uid: { type: 'string', optional: false },
permission: { type: 'string', optional: false },
extra: { type: 'object', optional: true },
meta: { type: 'object', optional: true },
}, req.body);
await svc_permission.grant_dev_app_permission(actor, req.body.app_uid, req.body.permission, req.body.extra || {}, req.body.meta || {});
res.json({});
});
================================================
FILE: src/backend/src/routers/auth/grant-user-app.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const { validate_fields } = require('../../util/validutil');
module.exports = eggspress('/auth/grant-user-app', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_permission = x.get('services').get('permission');
// Only users can grant user-app permissions
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
if ( req.body.origin ) {
const svc_auth = x.get('services').get('auth');
req.body.app_uid = await svc_auth.app_uid_from_origin(req.body.origin);
}
validate_fields({
app_uid: { type: 'string', optional: false },
permission: { type: 'string', optional: false },
extra: { type: 'object', optional: true },
meta: { type: 'object', optional: true },
}, req.body);
await svc_permission.grant_user_app_permission(actor, req.body.app_uid, req.body.permission, req.body.extra || {}, req.body.meta || {});
res.json({});
});
================================================
FILE: src/backend/src/routers/auth/grant-user-group.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const { validate_fields } = require('../../util/validutil');
module.exports = eggspress('/auth/grant-user-group', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_permission = x.get('services').get('permission');
// Only users can grant user-group permissions
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
validate_fields({
group_uid: { type: 'string', optional: false },
permission: { type: 'string', optional: false },
extra: { type: 'object', optional: true },
meta: { type: 'object', optional: true },
}, req.body);
await svc_permission.grant_user_group_permission(actor, req.body.group_uid, req.body.permission, req.body.extra || {}, req.body.meta || {});
res.json({});
});
================================================
FILE: src/backend/src/routers/auth/grant-user-user.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const { validate_fields } = require('../../util/validutil');
module.exports = eggspress('/auth/grant-user-user', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_permission = x.get('services').get('permission');
// Only users can grant user-user permissions
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
validate_fields({
target_username: { type: 'string', optional: false },
permission: { type: 'string', optional: false },
extra: { type: 'object', optional: true },
meta: { type: 'object', optional: true },
}, req.body);
await svc_permission.grant_user_user_permission(actor, req.body.target_username, req.body.permission, req.body.extra || {}, req.body.meta || {});
res.json({});
});
================================================
FILE: src/backend/src/routers/auth/list-permissions.js
================================================
/*
* 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 .
*/
import eggspress from '../../api/eggspress.js';
import { get_apps, get_user } from '../../helpers.js';
import { UserActorType } from '../../services/auth/Actor.js';
import { DB_READ } from '../../services/database/consts.js';
import { Context } from '../../util/context.js';
import { APIError } from '../../api/APIError.js';
export default eggspress('/auth/list-permissions', {
subdomain: 'api',
auth2: true,
allowedMethods: ['GET'],
}, async (_req, res, _next) => {
const x = Context.get();
const actor = x.get('actor');
// Apps cannot (currently) check permissions on behalf of users
if ( ! ( actor.type instanceof UserActorType ) ) {
throw APIError.create('forbidden');
}
const db = x.get('services').get('database').get(DB_READ, 'permissions');
const permissions = {};
{
permissions.myself_to_app = [];
const rows = await db.read('SELECT * FROM `user_to_app_permissions` WHERE user_id=?',
[ actor.type.user.id ]);
const apps = await get_apps(rows.map(row => ({ id: row.app_id })));
for ( let i = 0; i < rows.length; i++ ) {
const row = rows[i];
const app = apps[i];
if ( ! app ) continue;
delete app.id;
delete app.approved_for_listing;
delete app.approved_for_opening_items;
delete app.godmode;
delete app.owner_user_id;
const permission = {
app,
permission: row.permission,
extra: row.extra,
};
permissions.myself_to_app.push(permission);
}
}
{
permissions.myself_to_user = [];
const rows = await db.read('SELECT * FROM `user_to_user_permissions` WHERE issuer_user_id=?',
[ actor.type.user.id ]);
for ( const row of rows ) {
const user = await get_user({ id: row.holder_user_id });
const permission = {
user: user.username,
permission: row.permission,
extra: row.extra,
};
permissions.myself_to_user.push(permission);
}
}
{
permissions.user_to_myself = [];
const rows = await db.read('SELECT * FROM `user_to_user_permissions` WHERE holder_user_id=?',
[ actor.type.user.id ]);
for ( const row of rows ) {
const user = await get_user({ id: row.issuer_user_id });
const permission = {
user: user.username,
permission: row.permission,
extra: row.extra,
};
permissions.user_to_myself.push(permission);
}
}
res.json(permissions);
});
================================================
FILE: src/backend/src/routers/auth/list-sessions.js
================================================
/*
* 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 .
*/
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const APIError = require('../../api/APIError');
module.exports = eggspress('/auth/list-sessions', {
subdomain: 'api',
auth2: true,
allowedMethods: ['GET'],
}, async (req, res, next) => {
const x = Context.get();
const svc_auth = x.get('services').get('auth');
// Only users can list their own sessions
// apps, access tokens, etc should NEVER access this
const actor = x.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
const sessions = await svc_auth.list_sessions(actor);
res.json(sessions);
});
================================================
FILE: src/backend/src/routers/auth/oidc.js
================================================
/*
* 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 .
*/
import express from 'express';
import jwt from 'jsonwebtoken';
import config from '../../config.js';
import { get_user, subdomain } from '../../helpers.js';
const router = express.Router();
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes
const MISSING_CODE_OR_STATE = Symbol('MISSING_CODE_OR_STATE');
const INVALID_OR_EXPIRED_STATE = Symbol('INVALID_OR_EXPIRED_STATE');
const TOKEN_EXCHANGE_FAILED = Symbol('TOKEN_EXCHANGE_FAILED');
const COULD_NOT_GET_USER_INFO = Symbol('COULD_NOT_GET_USER_INFO');
const OIDC_CALLBACK_ERROR_RESPONSES = {
[MISSING_CODE_OR_STATE]: { status: 400, message: 'Missing code or state.' },
[INVALID_OR_EXPIRED_STATE]: { status: 400, message: 'Invalid or expired state.' },
[TOKEN_EXCHANGE_FAILED]: { status: 401, message: 'Token exchange failed.' },
[COULD_NOT_GET_USER_INFO]: { status: 401, message: 'Could not get user info.' },
};
const OIDC_ERROR_REDIRECT_MAP = {
login: {
account_not_found: 'signup',
other: 'login',
},
signup: {
account_already_exists: 'login',
other: 'signup',
},
};
/**
* The error redirect URL is the origin with a query parameter included to
* display an error message on the login or signup page.
*
* In a popup context, `stateDecoded` should contain the query parameters
* that reflect the popup state. `stateDecoded` is obtained from a JWT
* sent in the querystring of an OIDC callback page, and will decode to
* an object representing the query parameters that should go in the popup
* page invoked by puter.js
*
* @param {string} sourceFlow - 'login' or 'signup'
* @param {string} errorCondition - string that identifies the error message
* @param {string} message - default error message (before i18n)
* @param {object} [stateDecoded] - decoded OIDC state (may contain embedded_in_popup, msg_id for popup flow)
* @returns {string} URL to redirect to
*/
function buildOIDCErrorRedirectUrl (sourceFlow, errorCondition, message, stateDecoded) {
const targetFlow = OIDC_ERROR_REDIRECT_MAP[sourceFlow]?.[errorCondition] ?? sourceFlow;
const origin = (config.origin || '').replace(/\/$/, '') || '/';
const params = new URLSearchParams({ action: targetFlow, auth_error: '1', message: message || 'Something went wrong.' });
if ( stateDecoded?.embedded_in_popup && stateDecoded?.msg_id != null ) {
const popupParams = new URLSearchParams({
embedded_in_popup: 'true',
msg_id: String(stateDecoded.msg_id),
auth_error: '1',
message: message || 'Something went wrong.',
action: targetFlow,
});
if ( stateDecoded?.opener_origin ) {
popupParams.set('opener_origin', stateDecoded.opener_origin);
}
return `${origin}/?${popupParams.toString()}`;
}
return `${origin}/?${params.toString()}`;
}
/** Applies a query parameter to a URL */
function appendQueryParam (url, key, value) {
if ( !url || key == null ) return url;
const sep = url.includes('?') ? '&' : '?';
const encoded = `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
return `${url}${sep}${encoded}`;
}
/** Returns { session_token, target } for the caller to set cookie and redirect. */
const finishOidcSuccess_ = async (req, res, user, stateDecoded, extraQueryParams = null) => {
const svc_auth = req.services.get('auth');
const { token: session_token } = await svc_auth.create_session_token(user, { req });
let target = stateDecoded.redirect_uri || config.origin || '/';
const origin = config.origin || '';
if ( target && origin && !target.startsWith(origin) ) {
target = origin;
}
if ( extraQueryParams && typeof extraQueryParams === 'object' ) {
for ( const [k, v] of Object.entries(extraQueryParams) ) {
if ( v != null ) target = appendQueryParam(target, k, String(v));
}
}
return { session_token, target };
};
/** Exchange code for tokens, get userinfo. Returns { provider, userinfo, stateDecoded } or { error } (symbol). */
const processOIDCCallbackRequest_ = async (req, callbackRedirectUri) => {
const svc_oidc = req.services.get('oidc');
const code = req.query.code;
const state = req.query.state;
if ( !code || !state ) {
return { error: MISSING_CODE_OR_STATE };
}
const stateDecoded = svc_oidc.verifyState(state);
if ( !stateDecoded || !stateDecoded.provider ) {
return { error: INVALID_OR_EXPIRED_STATE };
}
const provider = stateDecoded.provider;
const tokens = await svc_oidc.exchangeCodeForTokens(provider, code, callbackRedirectUri);
if ( !tokens || !tokens.access_token ) {
return { error: TOKEN_EXCHANGE_FAILED };
}
const userinfo = await svc_oidc.getUserInfo(provider, tokens.access_token);
if ( !userinfo || !userinfo.sub ) {
return { error: COULD_NOT_GET_USER_INFO };
}
return { provider, userinfo, stateDecoded };
};
// GET /auth/oidc/providers - list enabled provider ids for frontend
router.get('/auth/oidc/providers', async (req, res) => {
if ( subdomain(req) !== 'api' ) {
return res.status(404).end();
}
const svc_oidc = req.services.get('oidc');
const providers = await svc_oidc.getEnabledProviderIds();
return res.json({ providers });
});
// GET /auth/oidc/:provider/start - redirect to IdP authorization
router.get('/auth/oidc/:provider/start', async (req, res) => {
if ( subdomain(req) !== '' ) {
return res.status(404).end();
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('oidc-general') ) {
return res.status(429).send('Too many requests.');
}
const provider = req.params.provider;
const svc_oidc = req.services.get('oidc');
const cfg = await svc_oidc.getProviderConfig(provider);
if ( ! cfg ) {
return res.status(404).send('Provider not configured.');
}
const flow = req.query.flow ? String(req.query.flow) : undefined;
const flowRedirects = {
login: config.origin || '/',
signup: config.origin || '/',
revalidate: `${(config.origin || '').replace(/\/$/, '')}/auth/revalidate-done`,
};
let appRedirectUri = (flow && flowRedirects[flow]) ? flowRedirects[flow] : (config.origin || '/');
const embeddedInPopup = req.query.embedded_in_popup === 'true' || req.query.embedded_in_popup === '1';
const msgId = req.query.msg_id != null && req.query.msg_id !== '' ? String(req.query.msg_id) : null;
const openerOrigin = req.query.opener_origin != null && req.query.opener_origin !== '' ? String(req.query.opener_origin) : null;
if ( embeddedInPopup && msgId ) {
const origin = (config.origin || '').replace(/\/$/, '');
appRedirectUri = `${origin}/action/sign-in?embedded_in_popup=true&msg_id=${encodeURIComponent(msgId)}`;
if ( openerOrigin ) {
appRedirectUri += `&opener_origin=${encodeURIComponent(openerOrigin)}`;
}
}
const statePayload = { provider, redirect_uri: appRedirectUri };
if ( embeddedInPopup && msgId ) {
statePayload.embedded_in_popup = true;
statePayload.msg_id = msgId;
if ( openerOrigin ) {
statePayload.opener_origin = openerOrigin;
}
}
if ( flow === 'revalidate' ) {
const user_id = req.query.user_id;
if ( ! user_id ) {
return res.status(400).send('user_id required for revalidate flow.');
}
statePayload.user_id = Number(user_id);
statePayload.flow = 'revalidate';
}
const state = svc_oidc.signState(statePayload);
const url = await svc_oidc.getAuthorizationUrl(provider, state, flow);
if ( ! url ) {
return res.status(502).send('Could not build authorization URL.');
}
return res.redirect(302, url);
});
// GET /auth/oidc/callback/login - login: existing account or create one if none exists.
router.get('/auth/oidc/callback/login', async (req, res) => {
if ( subdomain(req) !== '' ) {
return res.status(404).end();
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('oidc-general') ) {
return res.status(429).send('Too many requests.');
}
const svc_oidc = req.services.get('oidc');
const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('login');
const result = await processOIDCCallbackRequest_(req, callbackRedirectUri);
if ( result.error ) {
const { message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error];
return res.redirect(302, buildOIDCErrorRedirectUrl('login', 'other', message));
}
const { provider, userinfo, stateDecoded } = result;
let user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub);
if ( ! user ) {
// No account found: create one instead (login flow switches to signup).
const outcome = await svc_oidc.createUserFromOIDC(provider, userinfo);
if ( outcome.failed ) {
return res.redirect(302, buildOIDCErrorRedirectUrl('login', 'other', outcome.userMessage, stateDecoded));
}
user = await get_user({ id: outcome.infoObject.user_id });
}
if ( user.suspended ) {
return res.redirect(302, buildOIDCErrorRedirectUrl('login', 'other', 'This account is suspended.', stateDecoded));
}
const { session_token, target } = await finishOidcSuccess_(req, res, user, stateDecoded);
res.cookie(config.cookie_name, session_token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
return res.redirect(302, target);
});
// GET /auth/oidc/callback/signup - signup: create new account or log in to existing if already registered.
router.get('/auth/oidc/callback/signup', async (req, res) => {
if ( subdomain(req) !== '' ) {
return res.status(404).end();
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('oidc-general') ) {
return res.status(429).send('Too many requests.');
}
const svc_oidc = req.services.get('oidc');
const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('signup');
const result = await processOIDCCallbackRequest_(req, callbackRedirectUri);
if ( result.error ) {
const { message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error];
return res.redirect(302, buildOIDCErrorRedirectUrl('signup', 'other', message));
}
const { provider, userinfo, stateDecoded } = result;
const existingUser = await svc_oidc.findUserByProviderSub(provider, userinfo.sub);
if ( existingUser ) {
// Account already exists: log in instead and inform the user (signup flow switches to login).
const { session_token, target } = await finishOidcSuccess_(req, res, existingUser, stateDecoded, { oidc_switched: 'login' });
res.cookie(config.cookie_name, session_token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
return res.redirect(302, target);
}
const outcome = await svc_oidc.createUserFromOIDC(provider, userinfo);
if ( outcome.failed ) {
return res.redirect(302, buildOIDCErrorRedirectUrl('signup', 'other', outcome.userMessage, stateDecoded));
}
const user = await get_user({ id: outcome.infoObject.user_id });
const { session_token, target } = await finishOidcSuccess_(req, res, user, stateDecoded);
res.cookie(config.cookie_name, session_token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
return res.redirect(302, target);
});
// GET /auth/oidc/callback/revalidate - re-validate identity for protected actions (e.g. change username). Sets short-lived cookie and redirects.
router.get('/auth/oidc/callback/revalidate', async (req, res) => {
if ( subdomain(req) !== '' ) {
return res.status(404).end();
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('oidc-general') ) {
return res.status(429).send('Too many requests.');
}
const svc_oidc = req.services.get('oidc');
const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('revalidate');
const result = await processOIDCCallbackRequest_(req, callbackRedirectUri);
if ( result.error ) {
const { status, message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error];
return res.status(status).send(message);
}
const { provider, userinfo, stateDecoded } = result;
if ( stateDecoded.flow !== 'revalidate' || stateDecoded.user_id == null ) {
return res.status(400).send('Invalid revalidate state.');
}
const user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub);
if ( ! user ) {
return res.status(400).send('No account found.');
}
if ( user.id !== stateDecoded.user_id ) {
return res.status(403).send('Wrong account. Sign in with the account linked to this session.');
}
const token = jwt.sign(
{ user_id: user.id, purpose: 'revalidate' },
config.jwt_secret,
{ expiresIn: REVALIDATION_EXPIRY_SEC },
);
res.cookie(REVALIDATION_COOKIE_NAME, token, {
sameSite: 'lax',
secure: true,
httpOnly: true,
maxAge: REVALIDATION_EXPIRY_SEC * 1000,
path: '/',
});
const target = stateDecoded.redirect_uri || `${(config.origin || '').replace(/\/$/, '')}/auth/revalidate-done`;
return res.redirect(302, target);
});
// GET /auth/revalidate-done - landing page after OIDC revalidate; posts to opener and closes (for popup flow).
router.get('/auth/revalidate-done', (req, res) => {
if ( subdomain(req) !== '' ) {
return res.status(404).end();
}
const origin = config.origin || '';
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(`Re-validated Re-validated. Closing…
`);
});
export default router;
================================================
FILE: src/backend/src/routers/auth/request-app-root-dir.js
================================================
/*
* 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 .
*/
const eggspress = require('../../api/eggspress');
const APIError = require('../../api/APIError');
const { AppUnderUserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const { validate_fields } = require('../../util/validutil');
const { get_app } = require('../../helpers');
const { NodeInternalIDSelector } = require('../../filesystem/node/selectors');
const { HLStat } = require('../../filesystem/hl_operations/hl_stat');
const { PermissionUtil } = require('../../services/auth/permissionUtils.mjs');
const { quot } = require('@heyputer/putility').libs.string;
module.exports = eggspress('/auth/request-app-root-dir', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res) => {
const context = Context.get();
const actor = context.get('actor');
if ( ! (actor.type instanceof AppUnderUserActorType) ) {
throw APIError.create('forbidden', null, { debug_reason: 'not app actor' });
}
validate_fields({
app_uid: { type: 'string', optional: false },
access: { type: 'string', optional: false },
}, req.body);
const { app_uid: target_app_uid, access } = req.body;
if ( access !== 'read' && access !== 'write' ) {
throw APIError.create('field_invalid', null, {
key: 'access',
expected: "'read' or 'write'",
got: access,
});
}
if ( ! target_app_uid ) {
throw APIError.create('field_invalid', null, {
key: 'resource_request_code',
expected: 'app_uid',
got: target_app_uid,
});
}
const target_app = await get_app({ uid: target_app_uid });
if ( ! target_app ) {
throw APIError.create('entity_not_found', null, { identifier: `app:${target_app_uid}` });
}
if ( target_app.owner_user_id !== actor.type.user.id ) {
throw APIError.create('forbidden', null, {
debug_reason: 'Expected to match: ' +
`${quot(target_app.owner_user_id)} and ${quot(actor.type.user.id)}`,
});
}
const svc_app = context.get('services').get('app');
const root_dir_id = await svc_app.getAppRootDirId(target_app);
const svc_fs = context.get('services').get('filesystem');
const node = await svc_fs.node(new NodeInternalIDSelector('mysql', root_dir_id));
await node.fetchEntry();
if ( ! node.found ) {
throw APIError.create('subject_does_not_exist');
}
const node_uid = await node.get('uid');
const fs_perm = PermissionUtil.join('fs', node_uid, access);
const svc_permission = context.get('services').get('permission');
const has_perm = await svc_permission.check(actor, fs_perm);
if ( ! has_perm ) {
throw APIError.create('permission_denied', null, { permission: fs_perm });
}
const hl_stat = new HLStat();
const stat_result = await hl_stat.run({
subject: node,
user: actor.type.user,
return_subdomains: false,
return_permissions: false,
return_shares: false,
return_versions: false,
return_size: true,
});
res.json(stat_result);
});
================================================
FILE: src/backend/src/routers/auth/revoke-access-token.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { Context } = require('../../util/context');
/**
* Coerces a read-URL string to the token (JWT) from its query.
* Works for absolute or relative URLs (e.g. .../token-read?uid=...&token=...).
* Returns the given value unchanged if it does not look like a read URL.
*/
function tokenOrUuidFromInput (value) {
if ( typeof value !== 'string' || !value.trim() ) {
return value;
}
const s = value.trim();
console.log('s?', s);
if ( s.includes('/token-read') ) {
try {
const url = new URL(s);
const token = url.searchParams.get('token');
console.log('token?', token);
return token ?? s;
} catch (_) {
return s;
}
}
return s;
}
module.exports = eggspress('/auth/revoke-access-token', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_auth = x.get('services').get('auth');
const raw = req.body.tokenOrUuid;
if ( raw === undefined || raw === null ) {
throw APIError.create('field_missing', null, { key: 'tokenOrUuid' });
}
const tokenOrUuid = tokenOrUuidFromInput(raw);
await svc_auth.revoke_access_token(tokenOrUuid);
res.json({ ok: true });
});
================================================
FILE: src/backend/src/routers/auth/revoke-dev-app.js
================================================
/*
* 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 .
*/
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const APIError = require('../../api/APIError');
module.exports = eggspress('/auth/revoke-dev-app', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_permission = x.get('services').get('permission');
// Only users can grant user-app permissions
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
if ( req.body.origin ) {
const svc_auth = x.get('services').get('auth');
req.body.app_uid = await svc_auth.app_uid_from_origin(req.body.origin);
}
if ( ! req.body.app_uid ) {
throw APIError.create('field_missing', null, { key: 'app_uid' });
}
if ( req.body.permission === '*' ) {
await svc_permission.revoke_dev_app_all(actor, req.body.app_uid, req.body.meta || {});
}
await svc_permission.revoke_dev_app_permission(actor, req.body.app_uid, req.body.permission, req.body.meta || {});
res.json({});
});
================================================
FILE: src/backend/src/routers/auth/revoke-session.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
module.exports = eggspress('/auth/revoke-session', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_auth = x.get('services').get('auth');
// Only users can list their own sessions
// apps, access tokens, etc should NEVER access this
const actor = x.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
const svc_antiCSRF = req.services.get('anti-csrf');
if ( ! svc_antiCSRF.consume_token(actor.type.user.uuid, req.body.anti_csrf) ) {
return res.status(400).json({ message: 'incorrect anti-CSRF token' });
}
// Ensure valid UUID
if ( !req.body.uuid || typeof req.body.uuid !== 'string' ) {
throw APIError.create('field_invalid', null, {
key: 'uuid',
expected: 'string',
});
}
const sessions = await svc_auth.revoke_session(actor, req.body.uuid);
res.json({ sessions });
});
================================================
FILE: src/backend/src/routers/auth/revoke-user-app.js
================================================
/*
* 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 .
*/
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const APIError = require('../../api/APIError');
module.exports = eggspress('/auth/revoke-user-app', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_permission = x.get('services').get('permission');
// Only users can grant user-app permissions
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
if ( req.body.origin ) {
const svc_auth = x.get('services').get('auth');
req.body.app_uid = await svc_auth.app_uid_from_origin(req.body.origin);
}
if ( ! req.body.app_uid ) {
throw APIError.create('field_missing', null, { key: 'app_uid' });
}
if ( req.body.permission === '*' ) {
await svc_permission.revoke_user_app_all(actor, req.body.app_uid, req.body.meta || {});
}
await svc_permission.revoke_user_app_permission(actor, req.body.app_uid, req.body.permission, req.body.meta || {});
res.json({});
});
================================================
FILE: src/backend/src/routers/auth/revoke-user-group.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
module.exports = eggspress('/auth/revoke-user-group', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_permission = x.get('services').get('permission');
// Only users can grant user-user permissions
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
if ( ! req.body.group_uid ) {
throw APIError.create('field_missing', null, {
key: 'group_uid',
});
}
if ( ! req.body.permission ) {
throw APIError.create('field_missing', null, {
key: 'permission',
});
}
await svc_permission.revoke_user_group_permission(actor, req.body.group_uid, req.body.permission, req.body.meta || {});
res.json({});
});
================================================
FILE: src/backend/src/routers/auth/revoke-user-user.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
module.exports = eggspress('/auth/revoke-user-user', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const x = Context.get();
const svc_permission = x.get('services').get('permission');
// Only users can grant user-user permissions
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
if ( ! req.body.target_username ) {
throw APIError.create('field_missing', null, { key: 'target_username' });
}
await svc_permission.revoke_user_user_permission(actor, req.body.target_username, req.body.permission, req.body.meta || {});
res.json({});
});
================================================
FILE: src/backend/src/routers/change_email.js
================================================
/*
* 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 .
*/
'use strict';
const eggspress = require('../api/eggspress.js');
const APIError = require('../api/APIError.js');
const { DB_WRITE } = require('../services/database/consts.js');
const config = require('../config.js');
const jwt = require('jsonwebtoken');
const { invalidate_cached_user_by_id } = require('../helpers.js');
const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', {
allowedMethods: ['GET'],
}, async (req, res ) => {
const jwt_token = req.query.token;
if ( ! jwt_token ) {
throw APIError.create('field_missing', null, { key: 'token' });
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('change-email-confirm') ) {
return res.status(429).send('Too many requests.');
}
const { token, user_id } = jwt.verify(jwt_token, config.jwt_secret);
const db = req.services.get('database').get(DB_WRITE, 'auth');
const rows = await db.read(
'SELECT `unconfirmed_change_email`, `suspended` FROM `user` WHERE `id` = ? AND `change_email_confirm_token` = ?',
[user_id, token],
);
if ( rows.length === 0 ) {
throw APIError.create('token_invalid');
}
if ( rows[0].suspended ) {
throw APIError.create('forbidden');
}
const svc_cleanEmail = req.services.get('clean-email');
const clean_email = svc_cleanEmail.clean(rows[0].unconfirmed_change_email);
// Scenario: email was confirmed on another account already
const rows2 = await db.read(
'SELECT `id` FROM `user` WHERE `email` = ? OR `clean_email` = ?',
[rows[0].unconfirmed_change_email, clean_email],
);
if ( rows2.length > 0 ) {
throw APIError.create('email_already_in_use');
}
// If other users have the same unconfirmed email, revoke it
await db.write(
'UPDATE `user` SET `unconfirmed_change_email` = NULL, `email_confirmed`=1, `change_email_confirm_token` = NULL WHERE `id` = ?',
[user_id],
);
const new_email = rows[0].unconfirmed_change_email;
await db.write(
'UPDATE `user` SET `email` = ?, `clean_email` = ?, `unconfirmed_change_email` = NULL, `change_email_confirm_token` = NULL, `pass_recovery_token` = NULL WHERE `id` = ?',
[new_email, clean_email, user_id],
);
const svc_event = req.services.get('event');
svc_event.emit('user.email-changed', {
user_id: user_id,
new_email,
});
invalidate_cached_user_by_id(user_id);
const svc_socketio = req.services.get('socketio');
svc_socketio.send({ room: user_id }, 'user.email_changed', {});
const h = 'Your email has been successfully confirmed.
';
return res.send(h);
});
module.exports = app => {
app.use(CHANGE_EMAIL_CONFIRM);
};
================================================
FILE: src/backend/src/routers/change_username.js
================================================
/*
* 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 .
*/
'use strict';
const config = require('../config');
const eggspress = require('../api/eggspress.js');
const { Context } = require('../util/context.js');
const { UserActorType } = require('../services/auth/Actor.js');
const APIError = require('../api/APIError.js');
const { DB_WRITE } = require('../services/database/consts');
module.exports = eggspress('/change_username', {
subdomain: 'api',
auth2: true,
verified: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const { username_exists, change_username } = require('../helpers');
const actor = Context.get('actor');
// Only users can change their username (apps can't do this)
if ( ! ( actor.type instanceof UserActorType ) ) {
throw APIError.create('forbidden');
}
// validation
if ( ! req.body.new_username )
{
throw APIError.create('field_missing', null, { key: 'new_username' });
}
// new_username must be a string
else if ( typeof req.body.new_username !== 'string' )
{
throw APIError.create('field_invalid', null, { key: 'new_username', expected: 'a string' });
}
else if ( ! req.body.new_username.match(config.username_regex) )
{
throw APIError.create('field_invalid', null, { key: 'new_username', expected: 'letters, numbers, underscore (_)' });
}
else if ( req.body.new_username.length > config.username_max_length )
{
throw APIError.create('field_too_long', null, { key: 'new_username', max_length: config.username_max_length });
}
// duplicate username check
if ( await username_exists(req.body.new_username) )
{
throw APIError.create('username_already_in_use', null, { username: req.body.new_username });
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('change-email-start') ) {
return res.status(429).send('Too many requests.');
}
const db = Context.get('services').get('database').get(DB_WRITE, 'auth');
// Has the user already changed their username twice this month?
const rows = await db.read('SELECT COUNT(*) AS `count` FROM `user_update_audit` ' +
`WHERE \`user_id\`=? AND \`reason\`=? AND ${
db.case({
mysql: '`created_at` > DATE_SUB(NOW(), INTERVAL 1 MONTH)',
sqlite: "`created_at` > datetime('now', '-1 month')",
})}`,
[req.user.id, 'change_username']);
if ( rows[0].count >= (config.max_username_changes ?? 2) ) {
throw APIError.create('too_many_username_changes');
}
// Update username change audit table
await db.write('INSERT INTO `user_update_audit` ' +
'(`user_id`, `user_id_keep`, `old_username`, `new_username`, `reason`) ' +
'VALUES (?, ?, ?, ?, ?)',
[
req.user.id, req.user.id,
req.user.username, req.body.new_username,
'change_username',
]);
await change_username(req.user.id, req.body.new_username);
res.json({});
});
================================================
FILE: src/backend/src/routers/confirmEmail/ConfirmEmailRedisCacheSpace.js
================================================
/*
* 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 .
*/
const ConfirmEmailRedisCacheSpace = {
key: ({ ipAddress, emailOrUsername }) => `confirm-email|${ipAddress}|${emailOrUsername}`,
};
export { ConfirmEmailRedisCacheSpace };
================================================
FILE: src/backend/src/routers/confirmEmail/confirm-email.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
const auth = require('../../middleware/auth.js');
const { DB_WRITE } = require('../../services/database/consts.js');
const APIError = require('../../api/APIError.js');
const { redisClient } = require('../../clients/redis/redisSingleton.js');
const { ConfirmEmailRedisCacheSpace } = require('./ConfirmEmailRedisCacheSpace.js');
const { invalidate_cached_user_by_id } = require('../../helpers.js');
// -----------------------------------------------------------------------//
// POST /confirm-email
// -----------------------------------------------------------------------//
router.post('/confirm-email', auth, express.json(), async (req, res, next) => {
// Either api. subdomain or no subdomain
if ( require('../../helpers.js').subdomain(req) !== 'api' && require('../../helpers.js').subdomain(req) !== '' )
{
next();
}
if ( ! req.body.code )
{
return res.status(400).send('code is required');
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('confirm-email') ) {
return res.status(429).send('Too many requests.');
}
// Modules
const db = req.services.get('database').get(DB_WRITE, 'auth');
// Increment & check rate limit
const rateLimitKey = ConfirmEmailRedisCacheSpace.key({
ipAddress: req.ip,
emailOrUsername: req.user.email ?? req.user.username,
});
if ( await redisClient.incr(rateLimitKey) > 10 )
{
return res.status(429).send({ error: 'Too many requests.' });
}
// Set expiry for rate limit
redisClient.expire(rateLimitKey, 60 * 10, 'NX');
// Force a primary read so confirmation checks do not rely on possibly stale cache entries.
const svc_getUser = req.services.get('get-user');
const user = await svc_getUser.get_user({ id: req.user.id, force: true });
if ( ! user ) {
APIError.create('user_not_found').write(res);
return;
}
if ( String(req.body.code) !== String(user.email_confirm_code) ) {
res.send({ email_confirmed: false });
return;
}
// Scenario: email was confirmed on another account already
{
const svc_cleanEmail = req.services.get('clean-email');
const clean_email = svc_cleanEmail.clean(user.email);
if ( ! await svc_cleanEmail.validate(clean_email) ) {
APIError.create('field_invalid', null, {
key: 'email',
expected: 'valid email',
got: req.body.email,
});
}
const rows = await db.read(`SELECT EXISTS(
SELECT 1 FROM user WHERE (email=? OR clean_email=?) AND email_confirmed=1 AND password IS NOT NULL
) AS email_exists`, [user.email, clean_email]);
if ( rows[0].email_exists ) {
APIError.create('email_already_in_use').write(res);
return;
}
}
// If other users have the same unconfirmed email, revoke it
await db.write(
'UPDATE `user` SET `unconfirmed_change_email` = NULL, `change_email_confirm_token` = NULL WHERE `unconfirmed_change_email` = ?',
[user.email],
);
// Update user record to say email is confirmed
await db.write(
'UPDATE `user` SET `email_confirmed` = 1, `requires_email_confirmation` = 0 WHERE id = ? LIMIT 1',
[user.id],
);
// Invalidate user cache
await invalidate_cached_user_by_id(req.user.id);
// Emit internal event
const svc_event = req.services.get('event');
svc_event.emit('user.email-confirmed', {
user_uid: user.uuid,
email: user.email,
});
// Emit websocket event (TODO: should come from internal event above)
const svc_socketio = req.services.get('socketio');
svc_socketio.send({ room: user.id }, 'user.email_confirmed', {
original_client_socket_id: req.body.original_client_socket_id,
});
// return results
return res.send({
email_confirmed: true,
original_client_socket_id: req.body.original_client_socket_id,
});
});
module.exports = router;
================================================
FILE: src/backend/src/routers/contactUs.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth.js');
const { get_user, generate_random_str } = require('../helpers');
const { DB_WRITE } = require('../services/database/consts.js');
// -----------------------------------------------------------------------//
// POST /contactUs
// -----------------------------------------------------------------------//
router.post('/contactUs', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// message is required
if ( ! req.body.message )
{
return res.status(400).send({ message: 'message is required' });
}
// message must be a string
if ( typeof req.body.message !== 'string' )
{
return res.status(400).send('message must be a string.');
}
// message is too long
else if ( req.body.message.length > 100000 )
{
return res.status(400).send({ message: 'message is too long' });
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('contact-us') ) {
return res.status(429).send('Too many requests.');
}
// modules
const db = req.services.get('database').get(DB_WRITE, 'feedback');
try {
db.write(`INSERT INTO feedback
(user_id, message) VALUES
( ?, ?)`,
[
//user_id
req.user.id,
//message
req.body.message,
]);
// get user
let user = await get_user({ id: req.user.id });
// send email to support
const svc_email = req.services.get('email');
svc_email.sendMail({
from: '"Puter" no-reply@puter.com', // sender address
to: 'support@puter.com', // list of receivers
replyTo: user.email === null ? undefined : user.email,
subject: `Your Feedback/Support Request (#${generate_random_str(4)})`, // Subject line
text: req.body.message,
});
return res.send({});
} catch (e) {
return res.status(400).send(e);
}
});
module.exports = router;
================================================
FILE: src/backend/src/routers/delete-site.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth.js');
const config = require('../config');
const { DB_WRITE } = require('../services/database/consts.js');
// -----------------------------------------------------------------------//
// POST /delete-site
// -----------------------------------------------------------------------//
router.post('/delete-site', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// validation
if ( req.body.site_uuid === undefined )
{
return res.status(400).send('site_uuid is required');
}
// modules
const db = req.services.get('database').get(DB_WRITE, 'subdomains:legacy');
await db.write('DELETE FROM subdomains WHERE user_id = ? AND uuid = ?',
[req.user.id, req.body.site_uuid]);
res.send({});
});
module.exports = router;
================================================
FILE: src/backend/src/routers/df.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const config = require('../config.js');
const router = new express.Router();
const auth = require('../middleware/auth.js');
// TODO: Why is this both a POST and a GET?
// -----------------------------------------------------------------------//
// POST /df
// -----------------------------------------------------------------------//
router.post('/df', auth, express.json(), async (req, response, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return response.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
const { df } = require('../helpers');
const svc_hostDiskUsage = req.services.get('host-disk-usage', { optional: true });
try {
// auth
response.send({
used: parseInt(await df(req.user.id)),
capacity: config.is_storage_limited ? (req.user.free_storage === undefined || req.user.free_storage === null) ? config.storage_capacity : req.user.free_storage : config.available_device_storage,
...(svc_hostDiskUsage ? svc_hostDiskUsage.get_extra() : {}),
});
} catch (e) {
console.log(e);
response.status(400).send();
}
});
// -----------------------------------------------------------------------//
// GET /df
// -----------------------------------------------------------------------//
router.get('/df', auth, express.json(), async (req, response, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return response.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
const { df } = require('../helpers');
const svc_hostDiskUsage = req.services.get('host-disk-usage', { optional: true });
try {
// auth
response.send({
used: parseInt(await df(req.user.id)),
capacity: config.is_storage_limited ? (req.user.free_storage === undefined || req.user.free_storage === null) ? config.storage_capacity : req.user.free_storage : config.available_device_storage,
...(svc_hostDiskUsage ? svc_hostDiskUsage.get_extra() : {}),
});
} catch (e) {
console.log(e);
response.status(400).send();
}
});
module.exports = router;
================================================
FILE: src/backend/src/routers/down.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const config = require('../config.js');
const { NodePathSelector } = require('../filesystem/node/selectors.js');
const { HLRead } = require('../filesystem/hl_operations/hl_read.js');
const { UserActorType } = require('../services/auth/Actor.js');
const configurable_auth = require('../middleware/configurable_auth.js');
const { subdomain } = require('../helpers');
const _path = require('path');
// -----------------------------------------------------------------------//
// GET /down
// -----------------------------------------------------------------------//
router.post('/down', express.json(), express.urlencoded({ extended: true }), configurable_auth(), async (req, res, next) => {
// check subdomain
const actor = req.actor;
if ( !actor || !(actor.type instanceof UserActorType) ) {
if ( subdomain(req) !== 'api' )
{
next();
}
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// check anti-csrf token
const svc_antiCSRF = req.services.get('anti-csrf');
if ( ! svc_antiCSRF.consume_token(req.user.uuid, req.body.anti_csrf) ) {
return res.status(400).json({ message: 'incorrect anti-CSRF token' });
}
// validation
if ( ! req.query.path )
{
return res.status(400).send('path is required');
}
// path must be a string
else if ( typeof req.query.path !== 'string' )
{
return res.status(400).send('path must be a string.');
}
else if ( req.query.path.trim() === '' )
{
return res.status(400).send('path cannot be empty');
}
// modules
const path = _path.resolve('/', req.query.path);
// cannot download the root, because it's a directory!
if ( path === '/' )
{
return res.status(400).send('Cannot download a directory.');
}
// resolve path to its FSEntry
const svc_fs = req.services.get('filesystem');
const fsnode = await svc_fs.node(new NodePathSelector(path));
// not found
if ( ! fsnode.exists() ) {
return res.status(404).send('File not found');
}
// stream data from S3
try {
res.setHeader('Content-Type', 'application/octet-stream');
res.attachment(await fsnode.get('name'));
const hl_read = new HLRead();
const stream = await hl_read.run({
fsNode: fsnode,
user: req.user,
});
return stream.pipe(res);
} catch (e) {
console.log(e);
return res.type('application/json').status(500).send({ message: 'There was an internal problem reading the file.' });
}
});
module.exports = router;
================================================
FILE: src/backend/src/routers/drivers/call.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { FileFacade } = require('../../services/drivers/FileFacade');
const { TypeSpec } = require('../../services/drivers/meta/Construct');
const { TypedValue } = require('../../services/drivers/meta/Runtime');
const { Context } = require('../../util/context');
const { TeePromise } = require('@heyputer/putility').libs.promise;
const { valid_file_size } = require('../../util/validutil');
let _handle_multipart;
const responseHelper = (res, result) => {
if ( result.result instanceof TypedValue ) {
const tv = result.result;
if ( TypeSpec.adapt({ $: 'stream' }).equals(tv.type) ) {
res.set('Content-Type', tv.type.raw.content_type);
if ( tv.type.raw.chunked ) {
res.set('Transfer-Encoding', 'chunked');
}
tv.value.pipe(res);
return;
}
// This is the
if ( typeof tv.value === 'object' ) {
tv.value.type_fallback = true;
}
res.json(tv.value);
return;
}
res.json(result);
};
/**
* POST /drivers/call
*
* This endpoint is used to call methods offered by driver interfaces.
* The implementation used by each interface depends on the user's
* configuration.
*
* The request body can be a JSON object or multipart/form-data.
* For multipart/form-data, the caller must be aware that all fields
* are required to be sent before files so that the request handler
* and underlying driver implementation can decide what to do with
* file streams as they come.
*
* Example request body:
* {
* "interface": "puter-ocr",
* "method": "recognize",
* "args": {
* "file": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB...
* }
* }
*/
module.exports = eggspress('/drivers/call', {
subdomain: 'api',
auth2: true,
// noReallyItsJson: true,
jsonCanBeLarge: true,
allowedMethods: ['POST'],
}, async (req, res) => {
const x = Context.get();
const svc_driver = x.get('services').get('driver');
let p_request = null;
let body;
if ( req.headers['content-type'].includes('multipart/form-data') ) {
({ params: body, p_data_end: p_request } = await _handle_multipart(req));
} else body = req.body;
const interface_name = body.interface;
const test_mode = body.test_mode;
let context = Context.get();
if ( test_mode ) context = context.sub({ test_mode: true });
const result = await context.arun(async () => {
return await svc_driver.call({
iface: interface_name,
driver: body.driver ?? body.service,
method: body.method,
format: body.format,
args: body.args,
});
});
// We can't wait for the request to finish before responding;
// consider the case where a driver method implements a
// stream transformation, thus the stream from the request isn't
// consumed until the response is being sent.
responseHelper(res, result);
// What we _can_ do is await the request promise while responding
// to ensure errors are caught here.
await p_request;
});
_handle_multipart = async (req) => {
const Busboy = require('busboy');
const { PassThrough } = require('stream');
const params = Object.create(null);
const files = [];
let file_index = 0;
const bb = Busboy({
headers: req.headers,
});
const p_data_end = new TeePromise();
const p_nonfile_data_end = new TeePromise();
bb.on('file', (fieldname, stream, _details) => {
p_nonfile_data_end.resolve();
const fileinfo = files[file_index++];
stream.pipe(fileinfo.stream);
});
const on_field = (fieldname, value) => {
const key_parts = fieldname.split('.');
const last_key = key_parts.pop();
let dst = params;
for ( let i = 0; i < key_parts.length; i++ ) {
if ( ! Object.prototype.hasOwnProperty.call(dst, key_parts[i]) ) {
dst[key_parts[i]] = Object.create(null);
}
if ( !dst[key_parts[i]] || typeof dst[key_parts[i]] !== 'object' || Array.isArray(dst[key_parts[i]]) ) {
throw new Error(`Tried to set member of non-object: ${key_parts[i]} in ${fieldname}`);
}
dst = dst[key_parts[i]];
}
if ( value && value.$ === 'file' ) {
const fileinfo = value;
const { v: size, ok: size_ok } =
valid_file_size(fileinfo.size);
if ( ! size_ok ) {
throw APIError.create('invalid_file_metadata');
}
fileinfo.size = size;
fileinfo.stream = new PassThrough();
const file_facade = new FileFacade();
file_facade.values.set('stream', fileinfo.stream);
fileinfo.facade = file_facade,
files.push(fileinfo);
value = file_facade;
}
if ( Object.prototype.hasOwnProperty.call(dst, last_key) ) {
if ( ! Array.isArray(dst[last_key]) ) {
dst[last_key] = [dst[last_key]];
}
dst[last_key].push(value);
} else {
dst[last_key] = value;
}
};
bb.on('field', (fieldname, value, _details) => {
const o = JSON.parse(value, (key, val) => {
if ( val !== null && typeof val === 'object' && !Array.isArray(val) ) {
return Object.assign(Object.create(null), val);
}
return val;
});
for ( const k in o ) {
on_field(k, o[k]);
}
});
bb.on('error', (err) => {
p_data_end.reject(err);
});
bb.on('close', () => {
p_data_end.resolve();
});
req.pipe(bb);
(async () => {
await p_data_end;
p_nonfile_data_end.resolve();
})();
await p_nonfile_data_end;
return { params, p_data_end };
};
================================================
FILE: src/backend/src/routers/drivers/list-interfaces.js
================================================
/*
* 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 .
*/
const eggspress = require('../../api/eggspress');
const { Interface } = require('../../services/drivers/meta/Construct');
const { Context } = require('../../util/context');
module.exports = eggspress('/drivers/list-interfaces', {
subdomain: 'api',
auth2: true,
allowedMethods: ['GET'],
}, async (req, res, next) => {
const x = Context.get();
const svc_driver = x.get('services').get('driver');
const interfaces_raw = await svc_driver.list_interfaces();
const interfaces = {};
for ( const interface_name in interfaces_raw ) {
if ( interfaces_raw[interface_name].no_sdk ) continue;
interfaces[interface_name] = (new Interface(interfaces_raw[interface_name],
{ name: interface_name })).serialize();
}
res.json(interfaces);
});
================================================
FILE: src/backend/src/routers/drivers/usage.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { UserActorType } = require('../../services/auth/Actor');
const { DB_READ } = require('../../services/database/consts');
const { Context } = require('../../util/context');
module.exports = eggspress('/drivers/usage', {
subdomain: 'api',
auth2: true,
allowedMethods: ['GET'],
}, async (req, res, next) => {
const x = Context.get();
const actor = x.get('actor');
// Apps cannot (currently) check usage on behalf of users
if ( ! ( actor.type instanceof UserActorType ) ) {
throw APIError.create('forbidden');
}
const db = x.get('services').get('database').get(DB_READ, 'drivers');
const usages = {
user: {}, // map[str(iface:method)]{date,count,max}
apps: {}, // []{app,map[str(iface:method)]{date,count,max}}
app_objects: {},
usages: [],
};
const event = {
actor,
usages: [],
};
const svc_event = x.get('services').get('event');
await svc_event.emit('usages.query', event);
usages.usages = event.usages;
const user_is_verified = actor.type.user.email_confirmed;
for ( const k in usages.apps ) {
usages.apps[k] = Object.values(usages.apps[k]);
}
res.json({
user: Object.values(usages.user),
apps: usages.apps,
app_objects: usages.app_objects,
usages: usages.usages,
});
});
================================================
FILE: src/backend/src/routers/drivers/xd.js
================================================
/*
* 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 .
*/
const eggspress = require('../../api/eggspress');
const init_client_js = code => {
return `
document.addEventListener('DOMContentLoaded', function() {
(${code})();
});
`;
};
const script = async function script () {
const call = async ({
interface_name,
method_name,
params,
}) => {
const response = await fetch('/drivers/call', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
interface: interface_name,
method: method_name,
params,
}),
});
return await response.json();
};
const fcall = async ({
interface_name,
method_name,
params,
}) => {
// multipart request
const form = new FormData();
form.append('interface', interface_name);
form.append('method', method_name);
for ( const k in params ) {
form.append(k, params[k]);
}
const response = await fetch('/drivers/call', {
method: 'POST',
body: form,
});
return await response.json();
};
/* global window */
window.addEventListener('message', async event => {
const { id, interface: interface_, method, params } = event.data;
let has_file = false;
for ( const k in params ) {
if ( params[k] instanceof File ) {
has_file = true;
break;
}
}
const result = has_file ? await fcall({
interface_name: interface_,
method_name: method,
params,
}) : await call({
interface_name: interface_,
method_name: method,
params,
});
const response = {
id,
result,
};
event.source.postMessage(response, event.origin);
});
};
/**
* POST /drivers/xd
*
* This endpoint services the document which receives
* cross-document messages from the SDK and forwards
* them to the Puter Driver API.
*/
module.exports = eggspress('/drivers/xd', {
auth: true,
allowedMethods: ['GET'],
}, async (req, res, next) => {
res.type('text/html');
res.send(`
Puter Driver API
`);
});
================================================
FILE: src/backend/src/routers/file.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
const { subdomain, validate_signature_auth, get_url_from_req, get_descendants, id2path, get_user, sign_file } = require('../helpers');
const { DB_WRITE } = require('../services/database/consts');
const { UserActorType } = require('../services/auth/Actor');
const { Actor } = require('../services/auth/Actor');
const { LLRead } = require('../filesystem/ll_operations/ll_read');
const { NodeRawEntrySelector } = require('../filesystem/node/selectors');
// -----------------------------------------------------------------------//
// GET /file
// -----------------------------------------------------------------------//
router.get('/file', async (req, res, next) => {
// services and "services"
/** @type {import('../services/MeteringService/MeteringService').MeteringService} */
const meteringService = req.services.get('meteringService').meteringService;
const log = req.services.get('log-service').create('/file');
const errors = req.services.get('error-service').create(log);
const db = req.services.get('database').get(DB_WRITE, 'filesystem');
// check subdomain
if ( subdomain(req) !== 'api' ) {
next();
}
// validate URL signature
try {
validate_signature_auth(get_url_from_req(req), 'read');
} catch (e) {
console.log(e);
return res.status(403).send(e);
}
let can_write = false;
try {
validate_signature_auth(get_url_from_req(req), 'write');
can_write = true;
} catch ( _e ) {
// slent fail
}
// modules
const uid = req.query.uid;
let download = req.query.download ?? false;
if ( download === 'true' || download === '1' || download === true ) {
download = true;
}
// retrieve FSEntry from db
const fsentry = await db.read('SELECT * FROM fsentries WHERE uuid = ? LIMIT 1', [uid]);
// FSEntry not found
if ( ! fsentry[0] )
{
return res.status(400).send({ message: 'No entry found with this uid' });
}
// check if item owner is suspended
const user = await get_user({ id: fsentry[0].user_id });
if ( user.suspended )
{
return res.status(401).send({ error: 'Account suspended' });
}
// ---------------------------------------------------------------//
// FSEntry is dir
// ---------------------------------------------------------------//
if ( fsentry[0].is_dir ) {
// convert to path
const dirpath = await id2path(fsentry[0].id);
// get all children of this dir
const children = await get_descendants(dirpath, await get_user({ id: fsentry[0].user_id }), 1);
const signed_children = [];
if ( children.length > 0 ) {
for ( const child of children ) {
// sign file
const signed_child = await sign_file(child,
can_write ? 'write' : 'read');
signed_children.push(signed_child);
}
}
// send to client
return res.send(signed_children);
}
// force download?
if ( download ) {
res.attachment(fsentry[0].name);
}
// record fsentry owner
res.resource_owner = fsentry[0].user_id;
// try to deduce content-type
const contentType = 'application/octet-stream';
// update `accessed`
db.write('UPDATE fsentries SET accessed = ? WHERE `id` = ?',
[Date.now() / 1000, fsentry[0].id]);
const range = req.headers.range;
const ownerActor = new Actor({
type: new UserActorType({
user: user,
}),
});
const fileSize = fsentry[0].size;
res.setHeader('Accept-Ranges', 'bytes');
const parseRangeHeader = (rangeHeader) => {
// Check if this is a multipart range request
if ( rangeHeader.includes(',') ) {
// For now, we'll only serve the first range in multipart requests
// as the underlying storage layer doesn't support multipart responses
const firstRange = rangeHeader.split(',')[0].trim();
const matches = firstRange.match(/bytes=(\d+)-(\d*)/);
if ( ! matches ) return null;
const start = parseInt(matches[1], 10);
const end = matches[2] ? parseInt(matches[2], 10) : null;
return { start, end, isMultipart: true };
}
// Single range request
const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if ( ! matches ) return null;
const start = parseInt(matches[1], 10);
const end = matches[2] ? parseInt(matches[2], 10) : null;
return { start, end, isMultipart: false };
};
//--------------------------------------------------
// Range
//--------------------------------------------------
if ( range ) {
res.status(206);
const rangeInfo = parseRangeHeader(req.headers['range']);
if ( rangeInfo ) {
const { start, end, isMultipart } = rangeInfo;
// For open-ended ranges, we need to calculate the actual end byte
let actualEnd = end;
let fileSize = null;
try {
fileSize = fsentry[0].size;
if ( end === null ) {
actualEnd = fileSize - 1; // File size is 1-based, end byte is 0-based
}
} catch (e) {
// If we can't get file size, we'll let the storage layer handle it
// and not set Content-Range header
actualEnd = null;
fileSize = null;
}
if ( actualEnd !== null ) {
const totalSize = fileSize !== null ? fileSize : '*';
const contentRange = `bytes ${start}-${actualEnd}/${totalSize}`;
res.set('Content-Range', contentRange);
}
// If this was a multipart request, modify the range header to only include the first range
if ( isMultipart ) {
req.headers['range'] = end !== null
? `bytes=${start}-${end}`
: `bytes=${start}-`;
}
}
}
//--------------------------------------------------
// No range
//--------------------------------------------------
// set content-type, if available
if ( contentType !== null ) {
res.setHeader('Content-Type', contentType);
}
const svc_filesystem = req.services.get('filesystem');
// stream data from S3
try {
/* eslint-disable */
const fsNode = await svc_filesystem.node(
new NodeRawEntrySelector(fsentry[0]),
);
/* eslint-enable */
const ll_read = new LLRead();
const stream = await ll_read.run({
range,
no_acl: true,
actor: req.actor ?? ownerActor,
fsNode,
});
return stream.pipe(res);
} catch (e) {
errors.report('read from storage', {
source: e,
trace: true,
alarm: true,
});
return res.type('application/json').status(500).send({ message: 'There was an internal problem reading the file.' });
}
});
module.exports = router;
================================================
FILE: src/backend/src/routers/filesystem_api/batch/PathResolver.js
================================================
/*
* 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 .
*/
const APIError = require('../../../api/APIError.js');
const { relativeSelector } = require('../../../filesystem/node/selectors.js');
const ERR_INVALID_PATHREF = 'Invalid path reference in path: ';
const ERR_UNKNOWN_PATHREF = 'Unknown path reference in path: ';
/**
* Resolves path references in batch requests.
*
* A path reference is a path that starts with a dollar sign ($).
* It will resolve to the path that was returned by the operation
* with the same name in its `as` field.
*
* For example, if the operation `mkdir` has an `as` field with the
* value `newdir`, then the path `$newdir` will resolve to the path
* that was returned by the `mkdir` operation.
*/
module.exports = class PathResolver {
constructor ({ actor }) {
this.references = {};
this.selectors = {};
this.meta = {};
this.actor = actor;
this.listeners = {};
this.log = globalThis.services.get('log-service').create('path-resolver');
}
/**
* putPath - Add a path reference.
*
* The path reference will be resolved to the given path.
*
* @param {string} refName - The name of the path reference.
* @param {string} path - The path to resolve to.
*/
putPath (refName, path) {
this.references[refName] = { path };
}
putSelector (refName, selector, meta) {
this.log.debug(`putSelector called for: ${refName}`);
this.selectors[refName] = selector;
this.meta[refName] = meta;
if ( ! this.listeners.hasOwnProperty(refName) ) return;
for ( const lis of this.listeners[refName] ) lis();
}
/**
* resolve - Resolve a path reference.
*
* If the given path does not start with a dollar sign ($),
* it will be returned as-is. Otherwise, the path reference
* will be resolved to the path that was given to `putPath`.
*
* @param {string} inputPath
* @returns {string} The resolved path.
*/
resolve (inputPath) {
const refName = this.getReferenceUsed(inputPath);
if ( refName === null ) return inputPath;
if ( ! this.references.hasOwnProperty(refName) ) {
throw APIError.create(400, ERR_UNKNOWN_PATHREF + refName);
}
return this.references[refName].path +
inputPath.substring(refName.length + 1);
}
async awaitSelector (inputPath) {
// TODO: I feel like there's a better way to get username
const username = this.actor.type.user.username;
if ( inputPath.startsWith('~/') ) {
return `/${username}/${inputPath.substring(2)}`;
}
if ( inputPath === '~' ) {
return `/${username}`;
}
if ( inputPath.startsWith('.') ) {
throw APIError.create('unresolved_relative_path', null, { path: inputPath });
}
const refName = this.getReferenceUsed(inputPath);
if ( refName === null ) return inputPath;
this.log.debug(`-- awaitSelector -- input path is ${inputPath}`);
this.log.debug(`-- awaitSelector -- refName is ${refName}`);
if ( ! this.selectors.hasOwnProperty(refName) ) {
this.log.debug('-- awaitSelector -- doing the await');
if ( ! this.listeners[refName] ) {
this.listeners[refName] = [];
}
await new Promise (rslv => {
this.listeners[refName].push(rslv);
});
}
const subpath = inputPath.substring(refName.length + 1);
const selector = this.selectors[refName];
return relativeSelector(selector, subpath);
}
getMeta (inputPath) {
const refName = this.getReferenceUsed(inputPath);
if ( refName === null ) return null;
return this.meta[refName];
}
getReferenceUsed (inputPath) {
if ( ! inputPath.startsWith('$') ) return null;
const endOfRefName = inputPath.includes('/')
? inputPath.indexOf('/', 1) : inputPath.length;
const refName = inputPath.substring(1, endOfRefName);
if ( refName === '' ) {
throw APIError.create(400, ERR_INVALID_PATHREF + inputPath);
}
return refName;
}
};
================================================
FILE: src/backend/src/routers/filesystem_api/batch/all.js
================================================
/*
* 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 .
*/
const APIError = require('../../../api/APIError');
const eggspress = require('../../../api/eggspress');
const { Context } = require('../../../util/context');
const Busboy = require('busboy');
const { BatchExecutor } = require('../../../filesystem/batch/BatchExecutor');
const { TeePromise } = require('@heyputer/putility').libs.promise;
const { MovingMode } = require('../../../util/opmath');
const { get_app } = require('../../../helpers');
const { valid_file_size } = require('../../../util/validutil');
const { OnlyOnceFn } = require('../../../util/fnutil.js');
module.exports = eggspress('/batch', {
subdomain: 'api',
verified: true,
auth2: true,
// json: true,
// files: ['file'],
// multest: true,
// multipart_jsons: ['operation'],
allowedMethods: ['POST'],
}, async (req, res, _next) => {
const log = req.services.get('log-service').create('batch');
const errors = req.services.get('error-service').create(log);
const x = Context.get();
x.set('dbrr_channel', 'batch');
let app;
if ( req.body.app_uid ) {
// eslint-disable-next-line no-unused-vars
app = await get_app({ uid: req.body.app_uid });
}
const expected_metadata = {
original_client_socket_id: undefined,
socket_id: undefined,
operation_id: undefined,
};
// Errors not within operations that can only be detected
// while the request is streaming will be assigned to this
// value.
let request_errors_ = [];
let frame;
const create_frame = () => {
const operationTraceSvc = x.get('services').get('operationTrace');
frame = operationTraceSvc.add_frame_sync('api:/batch', x)
.attr('gui_metadata', {
...expected_metadata,
user_id: req.user.id,
})
;
x.set(operationTraceSvc.ckey('frame'), frame);
const svc_clientOperation = x.get('services').get('client-operation');
const tracker = svc_clientOperation.add_operation({
name: 'batch',
tags: ['fs'],
frame,
metadata: {
user_id: req.user.id,
},
});
x.set(svc_clientOperation.ckey('tracker'), tracker);
};
// Make sure usage is cached
const sizeService = x.get('services').get('sizeService');
await sizeService.get_usage(req.user.id);
globalThis.average_chunk_size = new MovingMode({
alpha: 0.7,
initial: 1,
});
//-------------------------------------------------------------
// Variables used by busboy callbacks
//-------------------------------------------------------------
// --- library
const operation_requires_file = op_spec => {
if ( op_spec.op === 'write' ) return true;
return false;
};
if ( ! req.actor ) {
throw new Error('Actor is missing here');
}
const batch_exe = new BatchExecutor(x, {
log,
errors,
actor: req.actor,
});
// --- state
const pending_operations = [];
const response_promises = [];
const fileinfos = [];
let request_error = null;
const on_nonfile_data_end = OnlyOnceFn(() => {
if ( request_error ) {
return;
}
const indexes_to_remove = [];
for ( let i = 0 ; i < pending_operations.length ; i++ ) {
const op_spec = pending_operations[i];
if ( ! operation_requires_file(op_spec) ) {
indexes_to_remove.push(i);
log.debug(`executing ${op_spec.op}`);
response_promises[i] = batch_exe.exec_op(req, op_spec);
} else {
// no handler
}
}
for ( let i = indexes_to_remove.length - 1 ; i >= 0 ; i-- ) {
const index = indexes_to_remove[i];
pending_operations.splice(index, 1)[0];
}
});
//-------------------------------------------------------------
// Multipart processing (using busboy)
//-------------------------------------------------------------
const busboy = Busboy({
headers: req.headers,
});
const still_reading = new TeePromise();
busboy.on('field', (fieldname, value, details) => {
try {
if ( details.fieldnameTruncated ) {
throw new Error('fieldnameTruncated');
}
if ( details.valueTruncated ) {
throw new Error('valueTruncated');
}
if ( Object.prototype.hasOwnProperty.call(expected_metadata, fieldname) ) {
expected_metadata[fieldname] = value;
req.body[fieldname] = value;
return;
}
if ( fieldname === 'fileinfo' ) {
const fileinfo = JSON.parse(value);
const { v: size, ok: size_ok } = valid_file_size(fileinfo.size);
if ( ! size_ok ) {
throw APIError.create('invalid_file_metadata');
}
fileinfo.size = size;
fileinfos.push(fileinfo);
return;
}
if ( ! frame ) {
create_frame();
}
if ( fieldname === 'operation' ) {
const op_spec = JSON.parse(value);
batch_exe.total++;
pending_operations.push(op_spec);
response_promises.push(null);
return;
}
req.body[fieldname] = value;
} catch (e) {
request_error = e;
req.unpipe(busboy);
res.set('Connection', 'close');
res.sendStatus(400);
}
});
busboy.on('file', async (fieldname, stream ) => {
if ( batch_exe.total_tbd ) {
batch_exe.total_tbd = false;
on_nonfile_data_end();
}
if ( fileinfos.length == 0 ) {
request_errors_.push(new APIError('batch_too_many_files'));
stream.on('data', () => {
});
stream.on('end', () => {
stream.destroy();
});
return;
}
const file = fileinfos.shift();
file.stream = stream;
if ( pending_operations.length == 0 ) {
request_errors_.push(new APIError('batch_too_many_files'));
// Elimiate the stream
stream.on('data', () => {
});
stream.on('end', () => {
stream.destroy();
});
return;
}
const op_spec = pending_operations.shift();
// Copy thumbnail from fileinfo to the file object if provided
if ( file.thumbnail ) {
op_spec.thumbnail = file.thumbnail;
}
// index in response_promises is first null value
const index = response_promises.findIndex(p => p === null);
response_promises[index] = batch_exe.exec_op(req, op_spec, file);
// response_promises[index] = Promise.resolve(out);
});
busboy.on('close', () => {
log.debug('busboy close');
still_reading.resolve();
});
req.pipe(busboy);
//-------------------------------------------------------------
// Awaiting responses
//-------------------------------------------------------------
await still_reading;
on_nonfile_data_end();
if ( request_error ) {
return;
}
log.debug('waiting for operations');
let responsePromises = response_promises;
// let responsePromises = batch_exe.responsePromises;
const results = await Promise.all(responsePromises);
log.debug('sending response');
frame.done();
if ( pending_operations.length ) {
// eslint-disable-next-line no-unused-vars
for ( const _op_spec of pending_operations ) {
const err = new APIError('batch_missing_file');
request_errors_.push(err);
}
}
if ( request_errors_ ) {
results.push(...request_errors_.map(e => {
return e.serialize();
}));
}
res.status(batch_exe.hasError ? 218 : 200).send({ results });
});
================================================
FILE: src/backend/src/routers/filesystem_api/cache.js
================================================
/*
* 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 .
*/
'use strict';
const eggspress = require('../../api/eggspress.js');
const { Context } = require('../../util/context.js');
module.exports = eggspress('/cache/last-change-timestamp', {
subdomain: 'api',
auth2: true,
verified: true,
fs: true,
json: true,
allowedMethods: ['GET'],
}, async (req, res) => {
/** @type {import('../../clients/dynamodb/DynamoKVStore/DynamoKVStore.js').DynamoKVStore} */
const kvStore = Context.get('services').get('puter-kvstore');
const timestamp = await kvStore.get({ key: `last_change_timestamp:${req.user?.id}` });
res.json({ timestamp });
});
================================================
FILE: src/backend/src/routers/filesystem_api/copy.js
================================================
/*
* 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 .
*/
'use strict';
const eggspress = require('../../api/eggspress.js');
const FSNodeParam = require('../../api/filesystem/FSNodeParam.js');
const { HLCopy } = require('../../filesystem/hl_operations/hl_copy.js');
const { Context } = require('../../util/context.js');
const { getTracer } = require('../../util/otelutil.js');
// -----------------------------------------------------------------------//
// POST /copy
// -----------------------------------------------------------------------//
module.exports = eggspress('/copy', {
subdomain: 'api',
auth2: true,
verified: true,
fs: true,
json: true,
allowedMethods: ['POST'],
parameters: {
source: new FSNodeParam('source'),
destination: new FSNodeParam('destination'),
},
}, async (req, res) => {
const user = req.user;
const dedupe_name =
req.body.dedupe_name ??
req.body.change_name ?? false;
let frame;
{
const x = Context.get();
const operationTraceSvc = x.get('services').get('operationTrace');
frame = (await operationTraceSvc.add_frame('api:/copy'))
.attr('gui_metadata', {
original_client_socket_id: req.body.original_client_socket_id,
socket_id: req.body.socket_id,
operation_id: req.body.operation_id,
user_id: req.user.id,
item_upload_id: req.body.item_upload_id,
})
;
x.set(operationTraceSvc.ckey('frame'), frame);
}
const tracer = getTracer();
await tracer.startActiveSpan('filesystem_api.copy', async span => {
// === upcoming copy behaviour ===
const hl_copy = new HLCopy();
const response = await hl_copy.run({
destination_or_parent: req.values.destination,
source: req.values.source,
new_name: req.body.new_name,
overwrite: req.body.overwrite ?? false,
dedupe_name,
user: user,
});
span.end();
frame.done();
return res.send([ response ]);
});
// res.send(new_fsentries)
});
================================================
FILE: src/backend/src/routers/filesystem_api/delete.js
================================================
/*
* 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 .
*/
'use strict';
const config = require('../../config.js');
const eggspress = require('../../api/eggspress.js');
const { HLRemove } = require('../../filesystem/hl_operations/hl_remove.js');
const FSNodeParam = require('../../api/filesystem/FSNodeParam.js');
// -----------------------------------------------------------------------//
// POST /delete
// -----------------------------------------------------------------------//
module.exports = eggspress('/delete', {
subdomain: 'api',
auth2: true,
json: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
const user = req.user;
const paths = req.body.paths;
const recursive = req.body.recursive ?? false;
const descendants_only = req.body.descendants_only ?? false;
if ( paths === undefined )
{
return res.status(400).send('paths is required');
}
else if ( ! Array.isArray(paths) )
{
return res.status(400).send('paths must be an array');
}
else if ( paths.length === 0 )
{
return res.status(400).send('paths cannot be empty');
}
// try to delete each path in the array one by one (if glob, resolve first)
// TODO: remove this pseudo-batch
for ( const item_path of paths ) {
const target = await (new FSNodeParam('path')).consolidate({
req: { user },
getParam: () => item_path,
});
const hl_remove = new HLRemove();
await hl_remove.run({
target,
user,
recursive,
descendants_only,
});
// send realtime success msg to client
const svc_socketio = req.services.get('socketio');
svc_socketio.send({ room: req.user.id }, 'item.removed', {
path: item_path,
descendants_only: descendants_only,
});
}
res.send({});
});
================================================
FILE: src/backend/src/routers/filesystem_api/mkdir.js
================================================
/*
* 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 .
*/
'use strict';
const eggspress = require('../../api/eggspress');
const FSNodeParam = require('../../api/filesystem/FSNodeParam');
const { HLMkdir } = require('../../filesystem/hl_operations/hl_mkdir');
const { Context } = require('../../util/context');
const { boolify } = require('../../util/hl_types');
// -----------------------------------------------------------------------//
// POST /mkdir
// -----------------------------------------------------------------------//
module.exports = eggspress('/mkdir', {
subdomain: 'api',
verified: true,
auth2: true,
fs: true,
json: true,
allowedMethods: ['POST'],
parameters: {
parent: new FSNodeParam('parent', { optional: true }),
shortcut_to: new FSNodeParam('shortcut_to', { optional: true }),
},
}, async (req, res, next) => {
// validation
if ( req.body.path === undefined )
{
return res.status(400).send({ message: 'path is required' });
}
else if ( req.body.path === '' )
{
return res.status(400).send({ message: 'path cannot be empty' });
}
else if ( req.body.path === null )
{
return res.status(400).send({ message: 'path cannot be null' });
}
else if ( typeof req.body.path !== 'string' )
{
return res.status(400).send({ message: 'path must be a string' });
}
const overwrite = req.body.overwrite ?? false;
// modules
let frame;
{
const x = Context.get();
const operationTraceSvc = x.get('services').get('operationTrace');
frame = (await operationTraceSvc.add_frame('api:/mkdir'))
.attr('gui_metadata', {
original_client_socket_id: req.body.original_client_socket_id,
operation_id: req.body.operation_id,
user_id: req.user.id,
})
;
x.set(operationTraceSvc.ckey('frame'), frame);
}
// PEDANTRY: in theory there's no difference between creating an object just to call
// a method on it and calling a utility function. HLMkdir is a class because
// it uses traits and supports dependency injection, but those features are
// not concerns of this endpoint handler.
const hl_mkdir = new HLMkdir();
const response = await hl_mkdir.run({
parent: req.values.parent,
path: req.body.path,
overwrite: overwrite,
dedupe_name: req.body.dedupe_name ?? false,
create_missing_parents: boolify(req.body.create_missing_ancestors ??
req.body.create_missing_parents),
actor: req.actor,
shortcut_to: req.values.shortcut_to,
});
// TODO: maybe endpoint handlers are operations too. It would be much
// nicer to not have to explicitly call frame.done() here.
frame.done();
return res.send(response);
});
================================================
FILE: src/backend/src/routers/filesystem_api/move.js
================================================
/*
* 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 .
*/
'use strict';
const eggspress = require('../../api/eggspress.js');
const FSNodeParam = require('../../api/filesystem/FSNodeParam.js');
const { HLMove } = require('../../filesystem/hl_operations/hl_move.js');
const { Context } = require('../../util/context.js');
const { getTracer } = require('../../util/otelutil.js');
// -----------------------------------------------------------------------//
// POST /move
// -----------------------------------------------------------------------//
module.exports = eggspress('/move', {
subdomain: 'api',
auth2: true,
verified: true,
fs: true,
json: true,
allowedMethods: ['POST'],
parameters: {
source: new FSNodeParam('source'),
destination: new FSNodeParam('destination'),
},
}, async (req, res, next) => {
const dedupe_name =
req.body.dedupe_name ??
req.body.change_name ?? false;
let frame;
{
const x = Context.get();
const operationTraceSvc = x.get('services').get('operationTrace');
frame = (await operationTraceSvc.add_frame('api:/move'))
.attr('gui_metadata', {
original_client_socket_id: req.body.original_client_socket_id,
socket_id: req.body.socket_id,
operation_id: req.body.operation_id,
user_id: req.user.id,
item_upload_id: req.body.item_upload_id,
})
;
x.set(operationTraceSvc.ckey('frame'), frame);
}
const tracer = getTracer();
await tracer.startActiveSpan('filesystem_api.move', async span => {
const hl_move = new HLMove();
const response = await hl_move.run({
destination_or_parent: req.values.destination,
source: req.values.source,
user: req.user,
new_name: req.body.new_name,
overwrite: req.body.overwrite ?? false,
dedupe_name,
new_metadata: req.body.new_metadata,
create_missing_parents: req.body.create_missing_parents ?? false,
});
span.end();
frame.done();
res.send(response);
});
});
================================================
FILE: src/backend/src/routers/filesystem_api/read.js
================================================
/*
* 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 .
*/
'use strict';
const APIError = require('../../api/APIError.js');
const eggspress = require('../../api/eggspress');
const FSNodeParam = require('../../api/filesystem/FSNodeParam');
const { HLRead } = require('../../filesystem/hl_operations/hl_read');
module.exports = eggspress('/read', {
subdomain: 'api',
auth2: true,
verified: true,
fs: true,
json: true,
allowedMethods: ['GET'],
alias: {
path: 'file',
uid: 'file',
},
parameters: {
fsNode: new FSNodeParam('file'),
},
}, async (req, res, next) => {
const line_count = !req.query.line_count ? undefined : parseInt(req.query.line_count);
const byte_count = !req.query.byte_count ? undefined : parseInt(req.query.byte_count);
const offset = !req.query.offset ? undefined : parseInt(req.query.offset);
if ( line_count && (!Number.isInteger(line_count) || line_count < 1) ) {
throw new APIError(400, '`line_count` must be a positive integer');
}
if ( byte_count && (!Number.isInteger(byte_count) || byte_count < 1) ) {
throw new APIError(400, '`byte_count` must be a positive integer');
}
if ( offset && (!Number.isInteger(offset) || offset < 0) ) {
throw new APIError(400, '`offset` must be a positive integer');
}
if ( byte_count && line_count ) {
throw new APIError(400, 'cannot use both line_count and byte_count');
}
if ( offset && !byte_count ) {
throw APIError.create('field_only_valid_with_other_field', null, {
key: 'offset',
other_key: 'byte_count',
});
}
// Helper function to parse Range header
const parseRangeHeader = (rangeHeader) => {
// Check if this is a multipart range request
if ( rangeHeader.includes(',') ) {
// For now, we'll only serve the first range in multipart requests
// as the underlying storage layer doesn't support multipart responses
const firstRange = rangeHeader.split(',')[0].trim();
const matches = firstRange.match(/bytes=(\d+)-(\d*)/);
if ( ! matches ) return null;
const start = parseInt(matches[1], 10);
const end = matches[2] ? parseInt(matches[2], 10) : null;
return { start, end, isMultipart: true };
}
// Single range request
const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if ( ! matches ) return null;
const start = parseInt(matches[1], 10);
const end = matches[2] ? parseInt(matches[2], 10) : null;
return { start, end, isMultipart: false };
};
if ( req.headers['range'] ) {
res.status(206);
// Parse the Range header and set Content-Range
const rangeInfo = parseRangeHeader(req.headers['range']);
if ( rangeInfo ) {
const { start, end, isMultipart } = rangeInfo;
// For open-ended ranges, we need to calculate the actual end byte
let actualEnd = end;
let fileSize = null;
try {
fileSize = await req.values.fsNode.get('size');
if ( end === null ) {
actualEnd = fileSize - 1; // File size is 1-based, end byte is 0-based
}
} catch (e) {
// If we can't get file size, we'll let the storage layer handle it
// and not set Content-Range header
actualEnd = null;
fileSize = null;
}
if ( actualEnd !== null ) {
const totalSize = fileSize !== null ? fileSize : '*';
const contentRange = `bytes ${start}-${actualEnd}/${totalSize}`;
res.set('Content-Range', contentRange);
}
// If this was a multipart request, modify the range header to only include the first range
if ( isMultipart ) {
req.headers['range'] = end !== null
? `bytes=${start}-${end}`
: `bytes=${start}-`;
}
}
}
res.set({ 'Accept-Ranges': 'bytes' });
const hl_read = new HLRead();
const stream = await hl_read.run({
...(req.headers['range'] ? { range: req.headers['range'] } : {
line_count,
byte_count,
offset,
}),
fsNode: req.values.fsNode,
user: req.user,
version_id: req.query.version_id,
});
res.set('Content-Type', 'application/octet-stream');
stream.pipe(res);
});
================================================
FILE: src/backend/src/routers/filesystem_api/readdir-subdomains.mjs
================================================
/*
* Copyright (C) 2026-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 .
*/
import { Context } from '../../util/context.js';
import eggspress from '../../api/eggspress.js';
import { DB_READ } from '../../services/database/consts.js';
import config from '../../config.js';
// -----------------------------------------------------------------------//
// POST /readdir-subdomains
// -----------------------------------------------------------------------//
export default eggspress('/readdir-subdomains', {
subdomain: 'api',
auth2: true,
verified: true,
json: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const log = (() => {
return Context.get('services').get('log-service').create('readdir-subdomains', {
concern: 'filesystem',
});
})();
log.debug('readdir-subdomains: batch fetch subdomains');
const { directory_ids } = req.body;
if ( !Array.isArray(directory_ids) || directory_ids.length === 0 ) {
return res.status(400).send({
code: 'invalid_request',
message: 'directory_ids must be a non-empty array',
});
}
const user = req.user;
const db = Context.get().get('services').get('database').get(DB_READ, 'filesystem');
// Note: directory_ids are actually UUIDs (not database IDs) because fsentry.id is set to uuid in getSafeEntry()
// We need to convert UUIDs to database IDs first
// Convert UUIDs to database IDs
const uuidPlaceholders = directory_ids.map(() => '?').join(',');
const fsentries = await db.read(`SELECT id, uuid FROM fsentries WHERE uuid IN (${uuidPlaceholders})`,
directory_ids);
// Create maps: uuid -> db_id and db_id -> uuid
const uuidToDbId = new Map();
const dbIdToUuid = new Map();
for ( const fsentry of fsentries ) {
uuidToDbId.set(fsentry.uuid, fsentry.id);
dbIdToUuid.set(fsentry.id, fsentry.uuid);
}
const dbIds = Array.from(uuidToDbId.values());
if ( dbIds.length === 0 ) {
return res.send(directory_ids.map(dirUuid => ({
directory_id: dirUuid,
subdomains: [],
has_website: false,
})));
}
// Build the query with placeholders using database IDs
const placeholders = dbIds.map(() => '?').join(',');
const rows = await db.read(`SELECT root_dir_id, subdomain, uuid
FROM subdomains
WHERE root_dir_id IN (${placeholders}) AND user_id = ?`,
[...dbIds, user.id]);
// Group subdomains by database ID
const subdomainsByDbId = {};
for ( const row of rows ) {
if ( ! subdomainsByDbId[row.root_dir_id] ) {
subdomainsByDbId[row.root_dir_id] = [];
}
subdomainsByDbId[row.root_dir_id].push({
subdomain: row.subdomain,
address: `${config.protocol}://${row.subdomain}.puter.site`,
uuid: row.uuid,
});
}
// Build response: array of { directory_id, subdomains, has_website }
// Map back to original UUIDs (directory_ids)
const result = directory_ids.map(dirUuid => {
const dbId = uuidToDbId.get(dirUuid);
const subdomains = dbId ? (subdomainsByDbId[dbId] || []) : [];
const has_website = subdomains.length > 0;
return {
directory_id: dirUuid,
subdomains: subdomains,
has_website: has_website,
};
});
res.send(result);
return;
});
================================================
FILE: src/backend/src/routers/filesystem_api/readdir.js
================================================
/*
* 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 .
*/
'use strict';
const { Context } = require('../../util/context.js');
const eggspress = require('../../api/eggspress.js');
const FSNodeParam = require('../../api/filesystem/FSNodeParam.js');
const FlagParam = require('../../api/filesystem/FlagParam.js');
const { HLReadDir } = require('../../filesystem/hl_operations/hl_readdir.js');
// -----------------------------------------------------------------------//
// POST /readdir
// -----------------------------------------------------------------------//
module.exports = eggspress('/readdir', {
subdomain: 'api',
auth2: true,
verified: true,
fs: true,
json: true,
allowedMethods: ['POST'],
alias: {
path: 'subject',
uid: 'subject',
},
parameters: {
subject: new FSNodeParam('subject'),
recursive: new FlagParam('recursive', { optional: true }),
no_thumbs: new FlagParam('no_thumbs', { optional: true }),
no_assocs: new FlagParam('no_assocs', { optional: true }),
no_subdomains: new FlagParam('no_subdomains', { optional: true }),
},
}, async (req, res, next) => {
let log; {
const x = Context.get();
log = x.get('services').get('log-service').create('readdir', {
concern: 'filesystem',
});
log.debug(`readdir: ${req.body.subject || req.body.path || req.body.uid}`);
}
const subject = req.values.subject;
const recursive = req.values.recursive;
const no_thumbs = req.values.no_thumbs;
const no_assocs = req.values.no_assocs;
const no_subdomains = req.values.no_subdomains;
const hl_readdir = new HLReadDir();
const result = await hl_readdir.run({
subject,
recursive,
no_thumbs,
no_assocs,
no_subdomains,
user: req.user,
actor: req.actor,
});
// check for duplicate names
if ( ! recursive ) {
const names = new Set();
for ( const entry of result ) {
if ( names.has(entry.name) ) {
log.error(`Duplicate name: ${entry.name}`);
// throw new Error(`Duplicate name: ${entry.name}`);
}
names.add(entry.name);
}
}
res.send(result);
return;
});
================================================
FILE: src/backend/src/routers/filesystem_api/rename.js
================================================
/*
* 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 .
*/
'use strict';
const eggspress = require('../../api/eggspress.js');
const APIError = require('../../api/APIError.js');
const { Context } = require('../../util/context.js');
const FSNodeParam = require('../../api/filesystem/FSNodeParam.js');
const { DB_WRITE } = require('../../services/database/consts.js');
// -----------------------------------------------------------------------//
// POST /rename
// -----------------------------------------------------------------------//
module.exports = eggspress('/rename', {
subdomain: 'api',
auth2: true,
verified: true,
fs: true,
json: true,
allowedMethods: ['POST'],
alias: { uid: 'path' },
parameters: {
subject: new FSNodeParam('path'),
},
}, async (req, res, next) => {
if ( ! req.body.new_name ) {
throw APIError.create('field_missing', null, {
key: 'new_name',
});
}
if ( typeof req.body.new_name !== 'string' ) {
throw APIError.create('field_invalid', null, {
key: 'new_name',
expected: 'string',
got: typeof req.body.new_name,
});
}
// modules
const db = req.services.get('database').get(DB_WRITE, 'filesystem');
const mime = require('mime-types');
const { get_app, validate_fsentry_name, id2path } = require('../../helpers.js');
const _path = require('path');
// new_name validation
try {
validate_fsentry_name(req.body.new_name);
} catch (e) {
return res.status(400).send({
error: {
message: e.message,
},
});
}
const { subject } = req.values;
//get fsentry
if ( ! await subject.exists() ) {
throw APIError.create('subject_does_not_exist');
}
// Access control
{
const actor = Context.get('actor');
const svc_acl = Context.get('services').get('acl');
if ( ! await svc_acl.check(actor, subject, 'write') ) {
throw await svc_acl.get_safe_acl_error(actor, subject, 'write');
}
}
await subject.fetchEntry();
let fsentry = subject.entry;
// immutable
if ( fsentry.immutable ) {
return res.status(400).send({
error: {
message: 'Immutable: cannot rename.',
},
});
}
let res1;
// parent is root
if ( fsentry.parent_uid === null ) {
try {
res1 = await db.read('SELECT uuid FROM fsentries WHERE parent_uid IS NULL AND name = ? AND id != ? LIMIT 1',
[
//name
req.body.new_name,
await subject.get('mysql-id'),
]);
} catch (e) {
console.log(e);
}
}
// parent is regular dir
else {
res1 = await db.read('SELECT uuid FROM fsentries WHERE parent_uid = ? AND name = ? AND id != ? LIMIT 1',
[
//parent_uid
fsentry.parent_uid,
//name
req.body.new_name,
await subject.get('mysql-id'),
]);
}
if ( res1[0] ) {
throw APIError.create('item_with_same_name_exists', null, {
entry_name: req.body.new_name,
});
}
const old_path = await id2path(await subject.get('mysql-id'));
const new_path = _path.join(_path.dirname(old_path), req.body.new_name);
// update `name`
await db.write('UPDATE fsentries SET name = ?, path = ? WHERE id = ?',
[req.body.new_name, new_path, await subject.get('mysql-id')]);
const filesystem = req.services.get('filesystem');
await filesystem.update_child_paths(old_path, new_path, req.user.id);
// associated_app
let associated_app;
if ( fsentry.associated_app_id ) {
const app = await get_app({ id: fsentry.associated_app_id });
// remove some privileged information
delete app.id;
delete app.approved_for_listing;
delete app.approved_for_opening_items;
delete app.godmode;
delete app.owner_user_id;
// add to array
associated_app = app;
} else {
associated_app = {};
}
// send the fsentry of the new object created
const contentType = mime.contentType(req.body.new_name);
const return_obj = {
uid: req.body.uid,
name: req.body.new_name,
is_dir: fsentry.is_dir,
path: new_path,
old_path: old_path,
type: contentType || null,
associated_app: associated_app,
original_client_socket_id: req.body.original_client_socket_id,
};
// send realtime success msg to client
const svc_socketio = req.services.get('socketio');
svc_socketio.send({ room: req.user.id }, 'item.renamed', return_obj);
(async () => {
try {
const svc_event = req.services.get('event');
await svc_event.emit('fs.rename', {
uid: fsentry.uuid,
new_name: req.body.new_name,
});
} catch (e) {
const log = req.services.get('log-service').create('rename-endpoint');
const errors = req.services.get('error-service').create(log);
errors.report('emit.rename', {
alarm: true,
source: e,
});
}
})();
return res.send(return_obj);
});
================================================
FILE: src/backend/src/routers/filesystem_api/search.js
================================================
/*
* 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 .
*/
const eggspress = require('../../api/eggspress');
const { HLNameSearch } = require('../../filesystem/hl_operations/hl_name_search');
module.exports = eggspress('/search', {
subdomain: 'api',
auth2: true,
verified: true,
fs: true,
json: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const hl_name_search = new HLNameSearch();
const result = await hl_name_search.run({
actor: req.actor,
term: req.body.text,
});
res.send(result);
});
================================================
FILE: src/backend/src/routers/filesystem_api/stat.js
================================================
/*
* 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 .
*/
'use strict';
const eggspress = require('../../api/eggspress.js');
const FSNodeParam = require('../../api/filesystem/FSNodeParam');
const { HLStat } = require('../../filesystem/hl_operations/hl_stat.js');
module.exports = eggspress('/stat', {
subdomain: 'api',
auth2: true,
verified: true,
fs: true,
json: true,
allowedMethods: ['GET', 'POST'],
alias: {
path: 'subject',
uid: 'subject',
},
parameters: {
subject: new FSNodeParam('subject'),
},
}, async (req, res, next) => {
// modules
const hl_stat = new HLStat();
const result = await hl_stat.run({
subject: req.values.subject,
user: req.user,
return_subdomains: req.body.return_subdomains,
return_permissions: req.body.return_permissions,
return_shares: req.body.return_shares,
return_versions: req.body.return_versions,
return_size: req.body.return_size,
});
res.send(result);
});
================================================
FILE: src/backend/src/routers/filesystem_api/token-read.js
================================================
/*
* 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 .
*/
'use strict';
const APIError = require('../../api/APIError.js');
const eggspress = require('../../api/eggspress');
const FSNodeParam = require('../../api/filesystem/FSNodeParam');
const { HLRead } = require('../../filesystem/hl_operations/hl_read');
const { Context } = require('../../util/context');
const { AccessTokenActorType } = require('../../services/auth/Actor');
const mime = require('mime-types');
module.exports = eggspress('/token-read', {
subdomain: 'api',
verified: true,
fs: true,
json: true,
allowedMethods: ['GET'],
alias: {
path: 'file',
uid: 'file',
},
parameters: {
fsNode: new FSNodeParam('file'),
},
}, async (req, res, next) => {
const line_count = !req.query.line_count ? undefined : parseInt(req.query.line_count);
const byte_count = !req.query.byte_count ? undefined : parseInt(req.query.byte_count);
const offset = !req.query.offset ? undefined : parseInt(req.query.offset);
const access_jwt = req.query.token;
const svc_auth = Context.get('services').get('auth');
const actor = await svc_auth.authenticate_from_token(access_jwt);
if ( ! actor ) {
throw APIError.create('token_auth_failed');
}
if ( ! (actor.type instanceof AccessTokenActorType) ) {
throw APIError.create('token_auth_failed');
}
const context = Context.get();
context.set('actor', actor);
if ( line_count && (!Number.isInteger(line_count) || line_count < 1) ) {
throw new APIError(400, '`line_count` must be a positive integer');
}
if ( byte_count && (!Number.isInteger(byte_count) || byte_count < 1) ) {
throw new APIError(400, '`byte_count` must be a positive integer');
}
if ( offset && (!Number.isInteger(offset) || offset < 0) ) {
throw new APIError(400, '`offset` must be a positive integer');
}
if ( byte_count && line_count ) {
throw new APIError(400, 'cannot use both line_count and byte_count');
}
if ( offset && !byte_count ) {
throw APIError.create('field_only_valid_with_other_field', null, {
key: 'offset',
other_key: 'byte_count',
});
}
// Helper function to parse Range header
const parseRangeHeader = (rangeHeader) => {
// Check if this is a multipart range request
if ( rangeHeader.includes(',') ) {
// For now, we'll only serve the first range in multipart requests
// as the underlying storage layer doesn't support multipart responses
const firstRange = rangeHeader.split(',')[0].trim();
const matches = firstRange.match(/bytes=(\d+)-(\d*)/);
if ( ! matches ) return null;
const start = parseInt(matches[1], 10);
const end = matches[2] ? parseInt(matches[2], 10) : null;
return { start, end, isMultipart: true };
}
// Single range request
const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if ( ! matches ) return null;
const start = parseInt(matches[1], 10);
const end = matches[2] ? parseInt(matches[2], 10) : null;
return { start, end, isMultipart: false };
};
if ( req.headers['range'] ) {
res.status(206);
// Parse the Range header and set Content-Range
const rangeInfo = parseRangeHeader(req.headers['range']);
if ( rangeInfo ) {
const { start, end, isMultipart } = rangeInfo;
// For open-ended ranges, we need to calculate the actual end byte
let actualEnd = end;
let fileSize = null;
try {
fileSize = await req.values.fsNode.get('size');
if ( end === null ) {
actualEnd = fileSize - 1; // File size is 1-based, end byte is 0-based
}
} catch (e) {
// If we can't get file size, we'll let the storage layer handle it
// and not set Content-Range header
actualEnd = null;
fileSize = null;
}
if ( actualEnd !== null ) {
const totalSize = fileSize !== null ? fileSize : '*';
const contentRange = `bytes ${start}-${actualEnd}/${totalSize}`;
res.set('Content-Range', contentRange);
}
// If this was a multipart request, modify the range header to only include the first range
if ( isMultipart ) {
req.headers['range'] = end !== null
? `bytes=${start}-${end}`
: `bytes=${start}-`;
}
}
}
res.set({ 'Accept-Ranges': 'bytes' });
const hl_read = new HLRead();
const stream = await context.arun(async () => await hl_read.run({
...(req.headers['range'] ? { range: req.headers['range'] } : {
line_count,
byte_count,
offset,
}),
fsNode: req.values.fsNode,
user: req.user,
actor,
version_id: req.query.version_id,
}));
const name = await req.values.fsNode.get('name');
const mime_type = mime.contentType(name);
res.setHeader('Content-Type', mime_type);
stream.pipe(res);
});
================================================
FILE: src/backend/src/routers/filesystem_api/touch.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const auth = require('../../middleware/auth.js');
const config = require('../../config.js');
const { DB_WRITE } = require('../../services/database/consts.js');
// -----------------------------------------------------------------------//
// POST /touch
// -----------------------------------------------------------------------//
router.post('/touch', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../../helpers.js').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
const db = req.services.get('database').get(DB_WRITE, 'filesystem');
const { v4: uuidv4 } = require('uuid');
const _path = require('path');
const { convert_path_to_fsentry, validate_fsentry_name, chkperm } = require('../../helpers.js');
// validation
if ( req.body.path === undefined )
{
return res.status(400).send('path is required');
}
// path must be a string
else if ( typeof req.body.path !== 'string' )
{
return res.status(400).send('path must be a string.');
}
else if ( req.body.path.trim() === '' )
{
return res.status(400).send('path cannot be empty');
}
const dirpath = _path.dirname(_path.resolve('/', req.body.path));
const target_name = _path.basename(_path.resolve('/', req.body.path));
const set_accessed_to_now = req.body.set_accessed_to_now;
const set_modified_to_now = req.body.set_modified_to_now;
// cannot touch in root
if ( dirpath === '/' )
{
return res.status(400).send('Can not touch in root.');
}
// name validation
try {
validate_fsentry_name(target_name);
} catch (e) {
return res.status(400).send(e);
}
// convert dirpath to its fsentry
const parent = await convert_path_to_fsentry(dirpath);
// dirpath not found
if ( parent === false )
{
return res.status(400).send('Target path not found');
}
// check permission
if ( ! await chkperm(parent, req.user.id, 'write') )
{
return res.status(403).send({ code: 'forbidden', message: 'permission denied.' });
}
// check if a FSEntry with the same name exists under this path
const existing_fsentry = await convert_path_to_fsentry(_path.resolve('/', `${dirpath }/${ target_name}`));
// current epoch
const ts = Date.now() / 1000;
// set_accessed_to_now
if ( set_accessed_to_now ) {
await db.write(`INSERT INTO fsentries
(uuid, parent_uid, user_id, name, is_dir, created, modified, size) VALUES
( ?, ?, ?, ?, false, ?, ?, 0)
ON DUPLICATE KEY UPDATE accessed=?`,
[
//uuid
(existing_fsentry !== false) ? existing_fsentry.uuid : uuidv4(),
//parent_uid
(parent === null) ? null : parent.uuid,
//user_id
parent === null ? req.user.id : parent.user_id,
//name
target_name,
//created
ts,
//modified
ts,
//accessed
ts,
]);
}
// set_modified_to_now
else if ( set_modified_to_now ) {
await db.write(`INSERT INTO fsentries
(uuid, parent_uid, user_id, name, is_dir, created, modified, size) VALUES
( ?, ?, ?, ?, false, ?, ?, 0)
ON DUPLICATE KEY UPDATE modified=?`,
[
//uuid
(existing_fsentry !== false) ? existing_fsentry.uuid : uuidv4(),
//parent_uid
(parent === null) ? null : parent.uuid,
//user_id
parent === null ? req.user.id : parent.user_id,
//name
target_name,
//created
ts,
//modified
ts,
//modified
ts,
]);
} else {
await db.write(`INSERT INTO fsentries
(uuid, parent_uid, user_id, name, is_dir, created, modified, size) VALUES
( ?, ?, ?, ?, false, ?, ?, 0)
ON DUPLICATE KEY UPDATE accessed=?, modified=?, created=?`,
[
//uuid
(existing_fsentry !== false) ? existing_fsentry.uuid : uuidv4(),
//parent_uid
(parent === null) ? null : parent.uuid,
//user_id
parent === null ? req.user.id : parent.user_id,
//name
target_name,
//created
ts,
//modified
ts,
//accessed
ts,
//modified
ts,
//created
ts,
]);
}
return res.send('');
});
module.exports = router;
================================================
FILE: src/backend/src/routers/filesystem_api/update.js
================================================
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const FSNodeParam = require('../../api/filesystem/FSNodeParam');
const StringParam = require('../../api/filesystem/StringParam');
const { is_valid_url } = require('../../helpers');
const { Context } = require('../../util/context');
module.exports = eggspress('/update-fsentry-thumbnail', {
subdomain: 'api',
verified: true,
auth2: true,
fs: true,
json: true,
allowedMethods: ['POST'],
parameters: {
fsNode: new FSNodeParam('path'),
thumbnail: new StringParam('thumbnail'),
},
}, async (req, res, next) => {
if ( ! is_valid_url(req.values.thumbnail) ) {
throw new APIError.create('field_invalid', null, {
key: 'thumbnail',
expected: 'a valid URL',
got: typeof req.values.thumbnail,
});
}
if ( ! await req.values.fsNode.exists() ) {
throw new APIError.create('subject_does_not_exist');
}
const svc = Context.get('services');
const svc_mountpoint = svc.get('mountpoint');
const provider =
await svc_mountpoint.get_provider(req.values.fsNode.selector);
provider.update_thumbnail({
context: Context.get(),
node: req.values.fsNode,
thumbnail: req.body.thumbnail,
});
res.json({});
});
================================================
FILE: src/backend/src/routers/filesystem_api/write.js
================================================
/*
* 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 .
*/
'use strict';
const eggspress = require('../../api/eggspress.js');
const FSNodeParam = require('../../api/filesystem/FSNodeParam.js');
const { HLWrite } = require('../../filesystem/hl_operations/hl_write.js');
const { boolify } = require('../../util/hl_types.js');
const { Context } = require('../../util/context.js');
const Busboy = require('busboy');
const { TeePromise } = require('@heyputer/putility').libs.promise;
const APIError = require('../../api/APIError.js');
const { valid_file_size } = require('../../util/validutil.js');
// -----------------------------------------------------------------------//
// POST /up | /write
// -----------------------------------------------------------------------//
module.exports = eggspress(['/up', '/write'], {
subdomain: 'api',
verified: true,
auth2: true,
fs: true,
json: true,
allowedMethods: ['POST'],
// files: ['file'],
// multest: true,
alias: { uid: 'path' },
// parameters: {
// fsNode: new FSNodeParam('path'),
// target: new FSNodeParam('shortcut_to', { optional: true }),
// }
}, async (req, res, _next) => {
// Note: parameters moved here because the parameter
// middleware won't work while using busboy
const parameters = {
fsNode: new FSNodeParam('path'),
target: new FSNodeParam('shortcut_to', { optional: true }),
};
// modules
const { get_app } = require('../../helpers.js');
// Is this an entry for an app?
let app;
if ( req.body.app_uid ) {
app = await get_app({ uid: req.body.app_uid });
}
const x = Context.get();
let frame;
async () => {
const operationTraceSvc = x.get('services').get('operationTrace');
frame = (await operationTraceSvc.add_frame('api:/write'))
.attr('gui_metadata', {
original_client_socket_id: req.body.original_client_socket_id,
socket_id: req.body.socket_id,
operation_id: req.body.operation_id,
user_id: req.user.id,
item_upload_id: req.body.item_upload_id,
})
;
x.set(operationTraceSvc.ckey('frame'), frame);
const svc_clientOperation = x.get('services').get('client-operation');
const tracker = svc_clientOperation.add_operation({
frame,
metadata: {
user_id: req.user.id,
},
});
x.set(svc_clientOperation.ckey('tracker'), tracker);
};
//-------------------------------------------------------------
// Multipart processing (using busboy)
//-------------------------------------------------------------
const busboy = Busboy({ headers: req.headers });
let uploaded_file = null;
const p_ready = new TeePromise();
busboy.on('field', (fieldname, value, details) => {
if ( details.fieldnameTruncated ) {
throw new Error('fieldnameTruncated');
}
if ( details.valueTruncated ) {
throw new Error('valueTruncated');
}
req.body[fieldname] = value;
});
busboy.on('file', (fieldname, stream, details) => {
const {
filename, mimetype,
} = details;
const { v: size, ok: size_ok } =
valid_file_size(req.body.size);
if ( ! size_ok ) {
p_ready.reject(APIError.create('invalid_file_metadata'));
return;
}
uploaded_file = {
size: size,
name: filename,
mimetype,
stream,
// TODO: Standardize the fileinfo object
// thumbnailer expects `mimetype` to be `type`
type: mimetype,
// alias for name, used only in here it seems
originalname: filename,
};
p_ready.resolve();
});
busboy.on('error', err => {
console.log('GOT ERROR READING', err);
p_ready.reject(err);
});
busboy.on('close', () => {
p_ready.resolve();
});
req.pipe(busboy);
await p_ready;
// Copied from eggspress; needed here because we're using busboy
for ( const key in parameters ) {
const param = parameters[key];
if ( ! req.values ) req.values = {};
const values = req.method === 'GET' ? req.query : req.body;
const getParam = (key) => values[key];
const result = await param.consolidate({ req, getParam });
req.values[key] = result;
}
if ( req.body.size === undefined ) {
throw APIError.create('missing_expected_metadata', null, {
keys: ['size'],
});
}
const hl_write = new HLWrite();
const response = await hl_write.run({
destination_or_parent: req.values.fsNode,
specified_name: req.body.name,
fallback_name: uploaded_file.originalname,
overwrite: await boolify(req.body.overwrite),
dedupe_name: await boolify(req.body.dedupe_name),
shortcut_to: req.values.target,
create_missing_parents: boolify(req.body.create_missing_ancestors ??
req.body.create_missing_parents),
actor: req.actor,
user: req.user,
file: uploaded_file,
app_id: app ? app.id : null,
thumbnail: req.body.thumbnail,
});
if ( frame ) frame.done();
return res.send(response);
});
================================================
FILE: src/backend/src/routers/get-dev-profile.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const config = require('../config.js');
const router = new express.Router();
const auth = require('../middleware/auth.js');
// -----------------------------------------------------------------------//
// GET /get-dev-profile
// -----------------------------------------------------------------------//
router.get('/get-dev-profile', auth, express.json(), async (req, response, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return response.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// TODO: we currently invalidate the cache on every request, this is because a developer may
// have been approved for the incentive program from one server, but the cache on another server
// may not have been updated yet. This is a temporary solution until we implement a better way to
// handle this. The better way would be for different servers to communicate with each other
// when a developer is approved for the incentive program (or any other change that affects the
// cache) and update the cache on all servers.
require('../helpers').invalidate_cached_user(req.user);
const { get_user } = require('../helpers');
let dev = await get_user(req.user);
dev = dev ?? {};
try {
// auth
response.send({
first_name: dev.dev_first_name,
last_name: dev.dev_last_name,
approved_for_incentive_program: dev.dev_approved_for_incentive_program,
joined_incentive_program: dev.dev_joined_incentive_program,
paypal: dev.dev_paypal,
});
} catch (e) {
console.log(e);
response.status(400).send();
}
});
module.exports = router;
================================================
FILE: src/backend/src/routers/get-launch-apps.js
================================================
/*
* 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 .
*/
'use strict';
import { redisClient } from '../clients/redis/redisSingleton.js';
import { setRedisCacheValue } from '../clients/redis/cacheUpdate.js';
import { get_apps } from '../helpers.js';
import { RecentAppOpensRedisCacheSpace } from './recentAppOpens/RecentAppOpensRedisCacheSpace.js';
import { DB_READ } from '../services/database/consts.js';
const iconify_apps = async (context, { apps, size }) => {
const svc_appIcon = context.services.get('app-icon');
return await svc_appIcon.iconifyApps({ apps, size });
};
// -----------------------------------------------------------------------//
// GET /get-launch-apps
// -----------------------------------------------------------------------//
export default async (req, res) => {
let result = {};
const iconSize = req.query.icon_size;
// Verify query params
if ( iconSize ) {
const ALLOWED_SIZES = ['16', '32', '64', '128', '256', '512'];
if ( ! ALLOWED_SIZES.includes(iconSize) ) {
res.status(400).send({ error: 'Invalid icon_size' });
}
}
// -----------------------------------------------------------------------//
// Recommended apps
// -----------------------------------------------------------------------//
const svc_recommendedApps = req.services.get('recommended-apps');
result.recommended = await svc_recommendedApps.get_recommended_apps({
icon_size: iconSize,
});
// -----------------------------------------------------------------------//
// Recent apps
// -----------------------------------------------------------------------//
let apps = [];
const db = req.services.get('database').get(DB_READ, 'apps');
// First try the cache to see if we have recent apps
const cached_apps = await redisClient.get(RecentAppOpensRedisCacheSpace.key(req.user.id));
if ( cached_apps ) {
try {
apps = JSON.parse(cached_apps);
} catch (e) {
apps = [];
}
}
// If cache is empty, query the db and update the cache
if ( !apps || !Array.isArray(apps) || apps.length === 0 ) {
apps = await db.read(
'SELECT DISTINCT app_uid FROM app_opens WHERE user_id = ? GROUP BY app_uid ORDER BY MAX(_id) DESC LIMIT 10',
[req.user.id],
);
// Update cache with the results from the db (if any results were returned)
if ( apps && Array.isArray(apps) && apps.length > 0 ) {
await setRedisCacheValue(
RecentAppOpensRedisCacheSpace.key(req.user.id),
JSON.stringify(apps),
{ eventData: apps },
);
}
}
// prepare each app for returning to user by only returning the necessary fields
// and adding them to the retobj array
const recent_apps = await get_apps(apps.map(({ app_uid: uid }) => ({ uid })));
result.recent = recent_apps.map((app) => {
if ( ! app ) return null;
return {
uuid: app.uid,
name: app.name,
title: app.title,
icon: app.icon,
godmode: app.godmode,
maximize_on_start: app.maximize_on_start,
index_url: app.index_url,
};
}).filter(Boolean);
// Iconify apps
if ( iconSize ) {
result.recent = await iconify_apps({ services: req.services }, {
apps: result.recent,
size: iconSize,
});
}
return res.send(result);
};
================================================
FILE: src/backend/src/routers/get-launch-apps.test.js
================================================
/*
* 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 .
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as uuid from 'uuid';
vi.mock('../helpers.js', () => ({
get_apps: vi.fn(),
}));
import { get_apps } from '../helpers.js';
import get_launch_apps from './get-launch-apps';
const TEST_UUID_NAMESPACE = '5568ab95-229d-4d87-b98c-0b12680a9524';
const apps_names_expected_to_exist = [
'app-center',
'dev-center',
'editor',
];
const data_mockapps = (() => {
const data_mockapps = [];
// List of app names that get-launch-apps expects to exist
for ( const name of apps_names_expected_to_exist ) {
data_mockapps.push({
uid: `app-${ uuid.v5(name, TEST_UUID_NAMESPACE)}`,
name,
title: 'App Name',
icon: 'icon-goes-here',
godmode: false,
maximize_on_start: false,
index_url: 'index-url',
});
}
// An additional app that won't show up in taskbar
data_mockapps.push({
uid: `app-${ uuid.v5('hidden-app', TEST_UUID_NAMESPACE)}`,
name: 'hidden-app',
title: 'Hidden App',
icon: 'icon-goes-here',
godmode: false,
maximize_on_start: false,
index_url: 'index-url',
});
// An additional app tha only shows up in recents
data_mockapps.push({
uid: `app-${ uuid.v5('recent-app', TEST_UUID_NAMESPACE)}`,
name: 'recent-app',
title: 'Recent App',
icon: 'icon-goes-here',
godmode: false,
maximize_on_start: false,
index_url: 'index-url',
});
return data_mockapps;
})();
const data_appopens = [
{
app_uid: `app-${ uuid.v5('app-center', TEST_UUID_NAMESPACE)}`,
},
{
app_uid: `app-${ uuid.v5('editor', TEST_UUID_NAMESPACE)}`,
},
{
app_uid: `app-${ uuid.v5('recent-app', TEST_UUID_NAMESPACE)}`,
},
];
const get_mock_context = () => {
get_apps.mockImplementation(async (specifiers) => {
return specifiers.map(({ uid, name, id }) => {
if ( uid ) {
return data_mockapps.find(app => app.uid === uid);
}
if ( name ) {
return data_mockapps.find(app => app.name === name);
}
if ( id ) {
return data_mockapps.find(app => app.id === id);
}
return null;
});
});
const database_mock = {
read: async (query) => {
if ( query.includes('FROM app_opens') ) {
return data_appopens;
}
},
};
const recommendedApps_mock = {
get_recommended_apps: async () => {
return data_mockapps
.filter(app => apps_names_expected_to_exist.includes(app.name))
.map(app => ({
uuid: app.uid,
name: app.name,
title: app.title,
icon: app.icon,
godmode: app.godmode,
maximize_on_start: app.maximize_on_start,
index_url: app.index_url,
}));
},
};
const services_mock = {
get: (key) => {
if ( key === 'database' ) {
return {
get: () => database_mock,
};
}
if ( key === 'recommended-apps' ) {
return recommendedApps_mock;
}
},
};
const req_mock = {
user: {
id: 1 + Math.floor(Math.random() * 1000 ** 3),
},
services: services_mock,
send: vi.fn(),
};
const res_mock = {
send: vi.fn(),
};
return {
get_launch_apps,
req_mock,
res_mock,
spies: {
get_apps,
},
};
};
describe('GET /launch-apps', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return expected format', async () => {
// First call
{
const { get_launch_apps, req_mock, res_mock } = get_mock_context();
req_mock.query = {};
await get_launch_apps(req_mock, res_mock);
// << HOW TO FIX >>
// If you updated the list of recommended apps,
// you can simply update this number to match the new length
// expect(spies.get_apps).toHaveBeenCalledTimes(1);
}
// Second call
{
const { get_launch_apps, req_mock, res_mock, spies } = get_mock_context();
req_mock.query = {};
await get_launch_apps(req_mock, res_mock);
expect(res_mock.send).toHaveBeenCalledOnce();
const call = res_mock.send.mock.calls[0];
const response = call[0];
expect(response).toBeTypeOf('object');
expect(response).toHaveProperty('recommended');
expect(response.recommended).toBeInstanceOf(Array);
expect(response.recommended).toHaveLength(apps_names_expected_to_exist.length);
expect(response.recommended).toEqual(
data_mockapps
.filter(app => apps_names_expected_to_exist.includes(app.name))
.map(app => ({
uuid: app.uid,
name: app.name,
title: app.title,
icon: app.icon,
godmode: app.godmode,
maximize_on_start: app.maximize_on_start,
index_url: app.index_url,
})));
expect(response).toHaveProperty('recent');
expect(response.recent).toBeInstanceOf(Array);
expect(response.recent).toHaveLength(data_appopens.length);
expect(response.recent).toEqual(
data_mockapps
.filter(app => data_appopens.map(app_open => app_open.app_uid).includes(app.uid))
.map(app => ({
uuid: app.uid,
name: app.name,
title: app.title,
icon: app.icon,
godmode: app.godmode,
maximize_on_start: app.maximize_on_start,
index_url: app.index_url,
})));
expect(spies.get_apps).toHaveBeenCalledTimes(2);
expect(spies.get_apps).toHaveBeenCalledWith(
data_appopens.map(({ app_uid: uid }) => ({ uid })));
}
});
});
================================================
FILE: src/backend/src/routers/healthcheck.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const config = require('../config');
const router = new express.Router();
const normalizeHostDomain = (domain) => {
if ( typeof domain !== 'string' ) return null;
const normalizedDomain = domain.trim().toLowerCase().replace(/^\./, '');
if ( ! normalizedDomain ) return null;
try {
return new URL(`http://${normalizedDomain}`).hostname.toLowerCase();
} catch {
return normalizedDomain.split(':')[0] || null;
}
};
const hostMatchesDomain = (hostname, domain) => {
const normalizedHost = normalizeHostDomain(hostname);
const normalizedDomain = normalizeHostDomain(domain);
if ( !normalizedHost || !normalizedDomain ) return false;
return normalizedHost === normalizedDomain ||
normalizedHost.endsWith(`.${normalizedDomain}`);
};
const isHostedDomainRequest = (req) => {
const requestHost = normalizeHostDomain(req.hostname ?? req.headers?.host);
if ( ! requestHost ) return false;
const hostedDomains = new Set();
for ( const domain of [
config.static_hosting_domain,
config.static_hosting_domain_alt,
config.private_app_hosting_domain,
config.private_app_hosting_domain_alt,
] ) {
const normalizedDomain = normalizeHostDomain(domain);
if ( normalizedDomain ) {
hostedDomains.add(normalizedDomain);
}
}
return [...hostedDomains].some(hostedDomain =>
hostMatchesDomain(requestHost, hostedDomain));
};
// -----------------------------------------------------------------------//
// GET /healthcheck
// -----------------------------------------------------------------------//
router.get('/healthcheck', async (req, res, next) => {
if ( isHostedDomainRequest(req) ) {
next();
return;
}
const svc_serverHealth = req.services.get('server-health');
const status = await svc_serverHealth.get_status();
res.status((req.query['return-http-error'] && !status.ok) ? 500 : 200).json(status);
});
module.exports = router;
================================================
FILE: src/backend/src/routers/hosting/puter-site-config.js
================================================
/*
* Copyright (C) 2026-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 .
*/
const path = require('path');
const ERROR_CLASS_REGEX = /^([45])xx$/i;
const STATUS_CODE_REGEX = /^[1-5][0-9][0-9]$/;
const createEmptyConfig = () => ({
exactRules: Object.create(null),
classRules: Object.create(null),
defaultRule: null,
});
const normalizeStatusCode = value => {
if ( value === undefined || value === null ) return null;
const status = Number.parseInt(String(value), 10);
if ( ! Number.isInteger(status) ) return null;
if ( status < 100 || status > 599 ) return null;
return status;
};
const normalizeFilePath = value => {
if ( typeof value !== 'string' ) return null;
let v = value.trim();
if ( v === '' ) return null;
if ( v.startsWith('@') ) return null;
if ( /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(v) ) return null;
v = v.replaceAll('\\', '/');
v = v.split('?')[0].split('#')[0];
if ( ! v.startsWith('/') ) {
v = `/${v}`;
}
const resolved = path.posix.resolve('/', v);
if ( resolved === '/' ) return null;
return resolved;
};
const normalizeRule = rawRule => {
if ( rawRule === undefined || rawRule === null ) return null;
if ( typeof rawRule === 'string' ) {
const file = normalizeFilePath(rawRule);
return file ? { file, status: null } : null;
}
if ( typeof rawRule === 'number' ) {
const status = normalizeStatusCode(rawRule);
return status ? { file: null, status } : null;
}
if ( typeof rawRule !== 'object' ) return null;
const file = normalizeFilePath(
rawRule.file ??
rawRule.path ??
rawRule.page ??
rawRule.responsePagePath ??
rawRule.response_page_path ??
rawRule.destination ??
rawRule.dest,
);
const status = normalizeStatusCode(
rawRule.status ??
rawRule.code ??
rawRule.statusCode ??
rawRule.responseCode ??
rawRule.response_code ??
rawRule.responseStatus ??
rawRule.response_status,
);
if ( !file && !status ) return null;
return { file: file ?? null, status: status ?? null };
};
const setRule = (config, key, rule) => {
if ( ! rule ) return false;
if ( key === 'default' ) {
config.defaultRule = rule;
return true;
}
if ( STATUS_CODE_REGEX.test(key) ) {
config.exactRules[key] = rule;
return true;
}
const classMatch = key.match(ERROR_CLASS_REGEX);
if ( classMatch ) {
config.classRules[`${classMatch[1]}xx`] = rule;
return true;
}
return false;
};
const parseKeyedRules = (config, object) => {
if ( !object || typeof object !== 'object' || Array.isArray(object) ) {
return false;
}
let matched = false;
for ( const [key, value] of Object.entries(object) ) {
if (
key !== 'default' &&
!STATUS_CODE_REGEX.test(key) &&
!ERROR_CLASS_REGEX.test(key)
) {
continue;
}
matched = setRule(config, key.toLowerCase(), normalizeRule(value)) || matched;
}
return matched;
};
const parseCloudfrontRules = (config, value) => {
if ( ! Array.isArray(value) ) return false;
let matched = false;
for ( const entry of value ) {
if ( !entry || typeof entry !== 'object' ) continue;
const errorCode = normalizeStatusCode(entry.ErrorCode ?? entry.errorCode ?? entry.error_code);
if ( ! errorCode ) continue;
const rule = normalizeRule({
responsePagePath: entry.ResponsePagePath ?? entry.responsePagePath ?? entry.response_page_path,
responseCode: entry.ResponseCode ?? entry.responseCode ?? entry.response_code,
});
if ( ! rule ) continue;
config.exactRules[String(errorCode)] = rule;
matched = true;
}
return matched;
};
const isCatchAllSource = source => {
if ( typeof source !== 'string' ) return false;
const s = source.trim();
if ( s === '' ) return false;
if ( [
'/:path*',
'/:match*',
'/(.*)',
'/(.*)?',
'/.*',
'^/(.*)$',
].includes(s) ) {
return true;
}
if ( /^\/:\w+\*$/.test(s) ) return true;
if ( /^\^?\/\(\.\*\)\$?$/.test(s) ) return true;
return false;
};
const parseVercelRules = (config, value) => {
if ( ! Array.isArray(value) ) return false;
let matched = false;
for ( const entry of value ) {
if ( !entry || typeof entry !== 'object' ) continue;
const source = entry.source ?? entry.src;
if ( ! isCatchAllSource(source) ) continue;
const rule = normalizeRule({
destination: entry.destination ?? entry.dest,
status: entry.status ?? 200,
});
if ( ! rule ) continue;
config.exactRules['404'] = rule;
matched = true;
}
return matched;
};
const parseJsonConfig = text => {
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
const config = createEmptyConfig();
let matched = false;
matched = parseCloudfrontRules(config, parsed?.CustomErrorResponses ?? parsed?.customErrorResponses) || matched;
matched = parseKeyedRules(config, parsed?.errors) || matched;
matched = parseKeyedRules(config, parsed?.errorPages) || matched;
matched = parseKeyedRules(config, parsed?.error_pages) || matched;
matched = parseKeyedRules(config, parsed) || matched;
const topLevelRule = normalizeRule(parsed);
if ( topLevelRule ) {
config.defaultRule = topLevelRule;
matched = true;
}
matched = parseVercelRules(config, parsed?.rewrites) || matched;
matched = parseVercelRules(config, parsed?.routes) || matched;
return matched ? config : null;
};
const parseNginxStyleConfig = text => {
const config = createEmptyConfig();
let matched = false;
const cleaned = text
.replace(/\r\n/g, '\n')
.replace(/#.*$/gm, '');
const directives = cleaned.matchAll(/\berror_page\s+([^;]+);/gi);
for ( const directive of directives ) {
const args = directive[1];
const tokens = args.trim().split(/\s+/).filter(Boolean);
if ( tokens.length < 2 ) continue;
const uriToken = tokens.pop();
const file = normalizeFilePath(uriToken);
if ( ! file ) continue;
let statusOverride = null;
if ( tokens.length > 0 && tokens[tokens.length - 1].startsWith('=') ) {
const overrideToken = tokens.pop();
if ( overrideToken !== '=' ) {
statusOverride = normalizeStatusCode(overrideToken.slice(1));
}
}
const statusCodes = tokens
.map(token => normalizeStatusCode(token))
.filter(Boolean);
if ( statusCodes.length === 0 ) continue;
const rule = {
file,
status: statusOverride,
};
for ( const statusCode of statusCodes ) {
config.exactRules[String(statusCode)] = rule;
matched = true;
}
}
return matched ? config : null;
};
const parseSiteErrorConfig = rawText => {
if ( typeof rawText !== 'string' ) return null;
const text = rawText.trim();
if ( text === '' ) return null;
const jsonConfig = parseJsonConfig(text);
if ( jsonConfig ) return jsonConfig;
return parseNginxStyleConfig(text);
};
const getSiteErrorRule = (config, statusCode) => {
if ( !config || typeof config !== 'object' ) return null;
const status = normalizeStatusCode(statusCode);
if ( ! status ) return null;
const exactRule = config.exactRules?.[String(status)];
if ( exactRule ) return { ...exactRule };
const classRule = config.classRules?.[`${Math.floor(status / 100)}xx`];
if ( classRule ) return { ...classRule };
if ( config.defaultRule ) return { ...config.defaultRule };
return null;
};
module.exports = {
parseSiteErrorConfig,
getSiteErrorRule,
};
================================================
FILE: src/backend/src/routers/hosting/puter-site-config.test.js
================================================
/*
* Copyright (C) 2026-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 .
*/
import { describe, expect, it } from 'vitest';
const {
parseSiteErrorConfig,
getSiteErrorRule,
} = require('./puter-site-config');
describe('puter-site-config parser', () => {
it('parses nginx error_page syntax', () => {
const config = parseSiteErrorConfig(`
error_page 404 /404.html;
error_page 500 502 503 504 =200 /index.html;
`);
expect(getSiteErrorRule(config, 404)).toEqual({
file: '/404.html',
status: null,
});
expect(getSiteErrorRule(config, 500)).toEqual({
file: '/index.html',
status: 200,
});
expect(getSiteErrorRule(config, 503)).toEqual({
file: '/index.html',
status: 200,
});
});
it('parses cloudfront custom error responses', () => {
const config = parseSiteErrorConfig(JSON.stringify({
CustomErrorResponses: [
{
ErrorCode: 404,
ResponsePagePath: '/404.html',
ResponseCode: '200',
},
{
ErrorCode: 500,
ResponseCode: '404',
},
],
}));
expect(getSiteErrorRule(config, 404)).toEqual({
file: '/404.html',
status: 200,
});
expect(getSiteErrorRule(config, 500)).toEqual({
file: null,
status: 404,
});
});
it('parses puter-native json with exact, wildcard, and default rules', () => {
const config = parseSiteErrorConfig(JSON.stringify({
errors: {
404: {
file: 'not-found.html',
},
'5xx': {
file: '/error.html',
status: 404,
},
default: {
status: 404,
},
},
}));
expect(getSiteErrorRule(config, 404)).toEqual({
file: '/not-found.html',
status: null,
});
expect(getSiteErrorRule(config, 502)).toEqual({
file: '/error.html',
status: 404,
});
expect(getSiteErrorRule(config, 418)).toEqual({
file: null,
status: 404,
});
});
it('parses vercel-style catch-all rewrite as 404 fallback', () => {
const config = parseSiteErrorConfig(JSON.stringify({
rewrites: [
{
source: '/:path*',
destination: '/index.html',
},
],
}));
expect(getSiteErrorRule(config, 404)).toEqual({
file: '/index.html',
status: 200,
});
});
it('returns null for unsupported config text', () => {
const config = parseSiteErrorConfig('this is not a supported config format');
expect(config).toBeNull();
});
});
================================================
FILE: src/backend/src/routers/hosting/puterSiteMiddleware.js
================================================
/*
* 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 .
*/
import dedent from 'dedent';
import { contentType as contentTypeFromMime } from 'mime-types';
import { resolve } from 'path';
import { v5 as uuidv5 } from 'uuid';
import APIError from '../../api/APIError.js';
import config from '../../config.js';
import fsNodeContext from '../../filesystem/FSNodeContext.js';
import llReadModule from '../../filesystem/ll_operations/ll_read.js';
import selectors from '../../filesystem/node/selectors.js';
import { get_app, get_user } from '../../helpers.js';
import api_error_handler from '../../modules/web/lib/api_error_handler.js';
import { Actor, SiteActorType, UserActorType } from '../../services/auth/Actor.js';
import { DB_READ } from '../../services/database/consts.js';
import { PermissionUtil } from '../../services/auth/permissionUtils.mjs';
import { Context } from '../../util/context.js';
import { stream_to_buffer as streamToBuffer } from '../../util/streamutil.js';
import {
getSiteErrorRule,
parseSiteErrorConfig,
} from './puter-site-config.js';
const {
origin: originUrl,
cookie_name: cookieName,
private_app_hosting_domain: privateAppHostingDomain,
private_app_hosting_domain_alt: privateAppHostingDomainAlt,
static_hosting_base_domain_redirect: staticHostingBaseDomainRedirect,
static_hosting_domain: staticHostingDomain,
static_hosting_domain_alt: staticHostingDomainAlt,
username_regex: usernameRegex,
} = config;
const { TYPE_DIRECTORY } = fsNodeContext;
const { LLRead } = llReadModule;
const {
NodeInternalIDSelector,
NodePathSelector,
} = selectors;
const AT_DIRECTORY_NAMESPACE = '4aa6dc52-34c1-4b8a-b63c-a62b27f727cf';
const puterSiteConfigFilename = '.puter_site_config';
const puterSiteConfigMaxSize = 256 * 1024;
const defaultPublicHostedActorCookieName = 'puter.public.hosted.actor.token';
function isPrivateApp (app) {
return Number(app?.is_private ?? 0) > 0;
}
function normalizeConfiguredHostname (hostValue) {
if ( typeof hostValue !== 'string' ) return null;
const normalizedHost = hostValue.trim().toLowerCase().replace(/^\./, '');
if ( ! normalizedHost ) return null;
try {
return new URL(`http://${normalizedHost}`).hostname.toLowerCase();
} catch {
return normalizedHost.split(':')[0] || null;
}
}
function getPrivateHostingDomainsForMatch () {
const domains = new Set();
for ( const candidate of [
privateAppHostingDomain,
privateAppHostingDomainAlt,
] ) {
const normalizedCandidate = normalizeConfiguredHostname(candidate);
if ( normalizedCandidate ) {
domains.add(normalizedCandidate);
}
}
return [...domains];
}
function getPrivateHostingDomainForRedirect () {
const primaryDomainCandidate = normalizeConfiguredHost(privateAppHostingDomain);
if ( primaryDomainCandidate ) return primaryDomainCandidate;
const altDomainCandidate = normalizeConfiguredHost(privateAppHostingDomainAlt);
if ( altDomainCandidate ) return altDomainCandidate;
return 'puter.app';
}
function hostMatchesPrivateDomain (hostname) {
const host = normalizeConfiguredHostname(hostname);
if ( ! host ) return false;
const privateHostingDomains = getPrivateHostingDomainsForMatch();
return privateHostingDomains.some(privateHostingDomain =>
host === privateHostingDomain || host.endsWith(`.${privateHostingDomain}`));
}
function getSubdomainFromHostedRequest (req) {
const host = normalizeConfiguredHostname(req.hostname);
if ( ! host ) return '';
const privateHostingDomains = getPrivateHostingDomainsForMatch()
.sort((a, b) => b.length - a.length);
for ( const privateHostingDomain of privateHostingDomains ) {
const privateDomainSuffix = `.${privateHostingDomain}`;
if ( host === privateHostingDomain ) {
return '';
}
if ( host.endsWith(privateDomainSuffix) ) {
const privateSubdomain = host.slice(0, host.length - privateDomainSuffix.length);
return privateSubdomain.split('.')[0] || '';
}
}
return host.split('.')[0] || '';
}
function getRequestedPrivateHost (req) {
const normalizedRequestHost = normalizeConfiguredHostname(req.hostname);
if ( ! normalizedRequestHost ) return undefined;
if ( ! hostMatchesPrivateDomain(normalizedRequestHost) ) return undefined;
return normalizedRequestHost;
}
function buildPrivateHostRedirectUrl (req, app) {
if ( ! app ) {
return null;
}
try {
const privateHostingDomain = getPrivateHostingDomainForRedirect();
if ( ! privateHostingDomain ) {
return null;
}
const subdomain = req.subdomains?.[0] || getSubdomainFromHostedRequest(req);
if ( ! subdomain ) {
return null;
}
const protocol = `${config.protocol ?? 'https'}`
.trim()
.replace(/:$/, '') || 'https';
const requestUrl = `${req.originalUrl || '/'}`.startsWith('/')
? req.originalUrl || '/'
: `/${req.originalUrl}`;
const privateHostOrigin = `${protocol}://${subdomain}.${privateHostingDomain}`;
const redirectUrl = new URL(requestUrl, privateHostOrigin);
return redirectUrl.toString();
} catch {
return null;
}
}
function normalizeHostFromHeader (hostValue) {
if ( typeof hostValue !== 'string' ) return null;
const normalizedHost = hostValue.trim().toLowerCase();
if ( ! normalizedHost ) return null;
try {
return new URL(`http://${normalizedHost}`).host;
} catch {
return normalizedHost;
}
}
function normalizeConfiguredHost (hostValue) {
if ( typeof hostValue !== 'string' ) return null;
const normalizedHost = hostValue.trim().toLowerCase().replace(/^\./, '');
if ( ! normalizedHost ) return null;
return normalizedHost;
}
function buildPrivateAppIndexUrlCandidates (req) {
const protocol = `${config.protocol ?? 'https'}`.trim().replace(/:$/, '') || 'https';
const hostCandidates = new Set();
const hostnameCandidate = normalizeHostFromHeader(req.hostname);
if ( hostnameCandidate ) {
hostCandidates.add(hostnameCandidate);
}
const headerHostCandidate = normalizeHostFromHeader(req.headers?.host);
if ( headerHostCandidate ) {
hostCandidates.add(headerHostCandidate);
}
const hostedSubdomain = getSubdomainFromHostedRequest(req);
if ( hostedSubdomain ) {
const staticHostingDomainCandidate = normalizeConfiguredHost(staticHostingDomain);
const staticHostingDomainAltCandidate = normalizeConfiguredHost(staticHostingDomainAlt);
const privateHostingDomainCandidate = normalizeConfiguredHost(privateAppHostingDomain);
const privateHostingDomainAltCandidate = normalizeConfiguredHost(privateAppHostingDomainAlt);
if ( staticHostingDomainCandidate ) {
hostCandidates.add(`${hostedSubdomain}.${staticHostingDomainCandidate}`);
}
if ( staticHostingDomainAltCandidate ) {
hostCandidates.add(`${hostedSubdomain}.${staticHostingDomainAltCandidate}`);
}
if ( privateHostingDomainCandidate ) {
hostCandidates.add(`${hostedSubdomain}.${privateHostingDomainCandidate}`);
}
if ( privateHostingDomainAltCandidate ) {
hostCandidates.add(`${hostedSubdomain}.${privateHostingDomainAltCandidate}`);
}
}
const candidates = [];
for ( const host of hostCandidates ) {
const base = `${protocol}://${host}`;
candidates.push(base);
candidates.push(`${base}/`);
candidates.push(`${base}/index.html`);
}
return [...new Set(candidates)];
}
async function resolvePrivateAppForHostedSite ({ req, site, services, associatedApp }) {
if ( associatedApp ) return associatedApp;
if ( ! site?.user_id ) return null;
const indexUrlCandidates = buildPrivateAppIndexUrlCandidates(req);
if ( indexUrlCandidates.length === 0 ) return null;
const databaseService = services.get('database');
const dbService = databaseService.get(DB_READ, 'apps');
const placeholders = indexUrlCandidates.map(() => '?').join(', ');
const apps = await dbService.read(
`SELECT * FROM apps WHERE owner_user_id = ? AND is_private = 1 AND index_url IN (${placeholders}) LIMIT 2`,
[site.user_id, ...indexUrlCandidates],
);
if ( apps.length > 1 ) {
logPrivateAccessEvent('private_access.host_match_ambiguous', {
requestHost: req.hostname,
siteOwnerUserId: site.user_id,
matchCount: apps.length,
});
}
return apps[0] || null;
}
function getPrivateDeniedRedirectUrl (app, denyRedirectUrl) {
if ( typeof denyRedirectUrl === 'string' && denyRedirectUrl.trim() ) {
return denyRedirectUrl.trim();
}
const origin = `${originUrl ?? ''}`.trim().replace(/\/$/, '');
if ( origin ) {
return `${origin}/app/app-center/?item=${encodeURIComponent(app?.uid ?? '')}`;
}
return '/';
}
function getMarketplaceAppUrl (app) {
const appName = typeof app?.name === 'string'
? app.name.trim()
: '';
if ( ! appName ) return null;
const origin = `${originUrl ?? ''}`.trim().replace(/\/$/, '');
if ( ! origin ) return null;
return `${origin}/app/${encodeURIComponent(appName)}/`;
}
function appendLinkHeader (res, linkValue) {
if ( ! linkValue ) return;
const existingValue = typeof res.get === 'function'
? res.get('Link')
: (
typeof res.getHeader === 'function'
? res.getHeader('Link')
: undefined
);
const setHeader = typeof res.set === 'function'
? (value) => res.set('Link', value)
: (
typeof res.setHeader === 'function'
? (value) => res.setHeader('Link', value)
: null
);
if ( ! setHeader ) return;
if ( ! existingValue ) {
setHeader(linkValue);
return;
}
setHeader(`${existingValue}, ${linkValue}`);
}
function setReferrerPolicyHeader (res, policyValue = 'no-referrer') {
const setHeader = typeof res.set === 'function'
? () => res.set('Referrer-Policy', policyValue)
: (
typeof res.setHeader === 'function'
? () => res.setHeader('Referrer-Policy', policyValue)
: null
);
if ( ! setHeader ) return;
setHeader();
}
function isPrivateAccessGateEnabled () {
return config.enable_private_app_access_gate !== false;
}
function logPrivateAccessEvent (eventName, fields = {}) {
console.info('private_access', {
eventName,
...fields,
});
}
function getPrivateAccessRejectionReason (error) {
return error?.code || error?.message || 'unknown';
}
function stripBootstrapAuthTokenFromOriginalUrl (originalUrl) {
if ( typeof originalUrl !== 'string' || !originalUrl ) return null;
try {
const placeholderOrigin = 'https://placeholder.puter.local';
const parsedUrl = new URL(originalUrl, placeholderOrigin);
const hadToken =
parsedUrl.searchParams.has('puter.auth.token')
|| parsedUrl.searchParams.has('auth_token');
if ( ! hadToken ) return null;
parsedUrl.searchParams.delete('puter.auth.token');
parsedUrl.searchParams.delete('auth_token');
const search = parsedUrl.searchParams.toString();
const cleanPath = parsedUrl.pathname || '/';
return search ? `${cleanPath}?${search}` : cleanPath;
} catch {
return null;
}
}
function hasAppInstanceIdQueryParam (req) {
const queryParamCandidates = [
req.query?.['puter.app_instance_id'],
req.query?.puter?.app_instance_id,
];
for ( const queryParamCandidate of queryParamCandidates ) {
if ( typeof queryParamCandidate === 'string' && queryParamCandidate.trim() ) {
return true;
}
}
if ( typeof req.originalUrl !== 'string' || !req.originalUrl ) {
return false;
}
try {
const placeholderOrigin = 'https://placeholder.puter.local';
const parsedUrl = new URL(req.originalUrl, placeholderOrigin);
const appInstanceId = parsedUrl.searchParams.get('puter.app_instance_id');
return typeof appInstanceId === 'string' && !!appInstanceId.trim();
} catch {
return false;
}
}
function getTokenFromAuthorizationHeader (req) {
const authorizationHeader = req.headers?.authorization;
if ( typeof authorizationHeader !== 'string' ) return null;
const match = authorizationHeader.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || null;
}
function getBootstrapTokenFromReferrer (req) {
const referrerHeader = req.headers?.referer ?? req.headers?.referrer;
if ( typeof referrerHeader !== 'string' || !referrerHeader.trim() ) {
return null;
}
try {
const referrerUrl = new URL(referrerHeader);
return referrerUrl.searchParams.get('puter.auth.token')
|| referrerUrl.searchParams.get('auth_token');
} catch {
return null;
}
}
function getBootstrapPrivateToken (req) {
const authorizationToken = getTokenFromAuthorizationHeader(req);
if ( authorizationToken ) return authorizationToken;
const queryTokenCandidates = [
req.query?.['puter.auth.token'],
req.query?.puter?.auth?.token,
req.query?.auth_token,
];
for ( const queryTokenCandidate of queryTokenCandidates ) {
if ( typeof queryTokenCandidate === 'string' && queryTokenCandidate.trim() ) {
return queryTokenCandidate.trim();
}
}
const headerToken = req.headers?.['x-puter-auth-token'];
if ( typeof headerToken === 'string' && headerToken.trim() ) {
return headerToken.trim();
}
return getBootstrapTokenFromReferrer(req);
}
function getBootstrapPrivateTokenSource (req) {
if ( getTokenFromAuthorizationHeader(req) ) {
return 'authorization';
}
if (
(typeof req.query?.['puter.auth.token'] === 'string' && req.query['puter.auth.token'].trim())
|| (typeof req.query?.puter?.auth?.token === 'string' && req.query.puter.auth.token.trim())
|| (typeof req.query?.auth_token === 'string' && req.query.auth_token.trim())
) {
return 'query';
}
if (
typeof req.headers?.['x-puter-auth-token'] === 'string'
&& req.headers['x-puter-auth-token'].trim()
) {
return 'x-puter-auth-token';
}
if ( getBootstrapTokenFromReferrer(req) ) {
return 'referrer';
}
return 'none';
}
function actorToPrivateIdentity (actor) {
if ( ! actor ) return null;
let userActor = null;
if ( actor.type instanceof UserActorType ) {
userActor = actor;
} else {
try {
userActor = actor.get_related_actor(UserActorType);
} catch {
userActor = null;
}
}
const userUid = userActor?.type?.user?.uuid;
if ( typeof userUid !== 'string' || !userUid ) {
return null;
}
const sessionCandidate = actor.type?.session ?? userActor.type?.session;
const sessionUuid = typeof sessionCandidate === 'string'
? sessionCandidate
: sessionCandidate?.uuid;
return {
userUid,
sessionUuid: typeof sessionUuid === 'string' && sessionUuid ? sessionUuid : undefined,
};
}
async function resolvePrivateIdentity ({ req, services, appUid }) {
const authService = services.get('auth');
const privateCookieName = authService.getPrivateAssetCookieName();
const privateCookieToken = req.cookies?.[privateCookieName];
const privateAppSubdomain = getSubdomainFromHostedRequest(req) || undefined;
const requestedPrivateHost = getRequestedPrivateHost(req);
const hasPrivateCookie = typeof privateCookieToken === 'string' && !!privateCookieToken;
let hasInvalidPrivateCookie = false;
let hostedOriginAppUid;
if ( typeof authService.app_uid_from_origin === 'function' ) {
try {
const protocol = `${config.protocol ?? 'https'}`
.trim()
.replace(/:$/, '') || 'https';
const requestedHostedOrigin = `${protocol}://${req.hostname}`;
const hostedOriginUid = await authService.app_uid_from_origin(requestedHostedOrigin);
if ( typeof hostedOriginUid === 'string' && hostedOriginUid ) {
hostedOriginAppUid = hostedOriginUid;
}
} catch {
// best effort only
}
}
const tokenAppUid = hostedOriginAppUid || appUid;
const expectedBootstrapAppUids = [tokenAppUid];
if ( appUid && appUid !== tokenAppUid ) {
expectedBootstrapAppUids.push(appUid);
}
if ( typeof privateCookieToken === 'string' && privateCookieToken ) {
try {
const claims = authService.verifyPrivateAssetToken(privateCookieToken, {
expectedAppUid: tokenAppUid,
expectedSubdomain: privateAppSubdomain,
expectedPrivateHost: requestedPrivateHost,
});
return {
source: 'private-cookie',
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
tokenAppUid,
subdomain: claims.subdomain || privateAppSubdomain,
privateHost: claims.privateHost || requestedPrivateHost,
hasValidPrivateCookie: true,
hasPrivateCookie,
hasInvalidPrivateCookie,
};
} catch (e) {
hasInvalidPrivateCookie = true;
logPrivateAccessEvent('private_access.identity_private_cookie_rejected', {
appUid,
requestHost: req.hostname,
reason: getPrivateAccessRejectionReason(e),
expectedAppUid: tokenAppUid ?? null,
expectedSubdomain: privateAppSubdomain ?? null,
expectedPrivateHost: requestedPrivateHost ?? null,
});
// fallback to next token source
}
}
const sessionToken = req.cookies?.[cookieName];
if ( typeof sessionToken === 'string' && sessionToken ) {
try {
const actor = await authService.authenticate_from_token(sessionToken);
const identity = actorToPrivateIdentity(actor);
if ( identity ) {
return {
source: 'session-cookie',
...identity,
tokenAppUid,
subdomain: privateAppSubdomain,
privateHost: requestedPrivateHost,
hasValidPrivateCookie: false,
hasPrivateCookie,
hasInvalidPrivateCookie,
};
}
} catch (e) {
logPrivateAccessEvent('private_access.identity_session_cookie_rejected', {
appUid,
requestHost: req.hostname,
reason: getPrivateAccessRejectionReason(e),
});
// fallback to next token source
}
}
const bootstrapToken = getBootstrapPrivateToken(req);
const bootstrapTokenSource = getBootstrapPrivateTokenSource(req);
if ( typeof bootstrapToken === 'string' && bootstrapToken ) {
let strictAuthError;
try {
const actor = await authService.authenticate_from_token(bootstrapToken);
const identity = actorToPrivateIdentity(actor);
if ( identity ) {
if ( typeof authService.resolvePrivateBootstrapIdentityFromToken === 'function' ) {
await authService.resolvePrivateBootstrapIdentityFromToken(bootstrapToken, {
expectedAppUids: expectedBootstrapAppUids,
});
}
return {
source: 'bootstrap-token',
...identity,
tokenAppUid,
subdomain: privateAppSubdomain,
privateHost: requestedPrivateHost,
hasValidPrivateCookie: false,
hasPrivateCookie,
hasInvalidPrivateCookie,
};
}
logPrivateAccessEvent('private_access.bootstrap_strict_missing_identity', {
appUid,
requestHost: req.hostname,
source: bootstrapTokenSource,
});
} catch (e) {
strictAuthError = e;
logPrivateAccessEvent('private_access.bootstrap_strict_rejected', {
appUid,
requestHost: req.hostname,
source: bootstrapTokenSource,
reason: getPrivateAccessRejectionReason(e),
});
}
if ( typeof authService.resolvePrivateBootstrapIdentityFromToken === 'function' ) {
try {
const identity = await authService.resolvePrivateBootstrapIdentityFromToken(bootstrapToken, {
expectedAppUids: expectedBootstrapAppUids,
});
if ( identity ) {
logPrivateAccessEvent('private_access.bootstrap_fallback_allowed', {
appUid,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
source: 'bootstrap-token',
});
return {
source: 'bootstrap-token',
...identity,
tokenAppUid,
subdomain: privateAppSubdomain,
privateHost: requestedPrivateHost,
hasValidPrivateCookie: false,
hasPrivateCookie,
hasInvalidPrivateCookie,
};
}
logPrivateAccessEvent('private_access.bootstrap_fallback_missing_identity', {
appUid,
requestHost: req.hostname,
source: bootstrapTokenSource,
strictReason: strictAuthError?.code || strictAuthError?.message || null,
});
} catch (e) {
logPrivateAccessEvent('private_access.bootstrap_fallback_rejected', {
appUid,
requestHost: req.hostname,
source: bootstrapTokenSource,
reason: e?.code || e?.message || 'unknown',
strictReason: strictAuthError?.code || strictAuthError?.message || null,
});
}
} else if ( strictAuthError ) {
logPrivateAccessEvent('private_access.bootstrap_rejected_no_fallback', {
appUid,
requestHost: req.hostname,
source: bootstrapTokenSource,
reason: getPrivateAccessRejectionReason(strictAuthError),
});
}
}
return {
source: 'none',
userUid: undefined,
sessionUuid: undefined,
tokenAppUid,
subdomain: privateAppSubdomain,
privateHost: requestedPrivateHost,
hasValidPrivateCookie: false,
hasPrivateCookie,
hasInvalidPrivateCookie,
};
}
function getPublicHostedActorCookieName (authService) {
if ( typeof authService?.getPublicHostedActorCookieName === 'function' ) {
return authService.getPublicHostedActorCookieName();
}
return defaultPublicHostedActorCookieName;
}
function getRequestedHostedHost (req) {
const normalizedHost = normalizeConfiguredHostname(req.hostname);
return normalizedHost || undefined;
}
function buildLightweightHostedActor ({ userUid, sessionUuid }) {
if ( typeof userUid !== 'string' || !userUid ) {
return null;
}
return new Actor({
user_uid: userUid,
type: new UserActorType({
user: { uuid: userUid },
...(sessionUuid ? { session: sessionUuid } : {}),
hasHttpOnlyCookie: false,
}),
});
}
function setHostedActorOnRequestContext ({ req, actor }) {
if ( ! actor ) return;
req.actor = actor;
Context.set('actor', actor);
}
async function resolvePublicHostedIdentity ({ req, services, appUid }) {
const authService = services.get('auth');
const publicHostedCookieName = getPublicHostedActorCookieName(authService);
const publicHostedCookieToken = req.cookies?.[publicHostedCookieName];
const hostedSubdomain = getSubdomainFromHostedRequest(req) || undefined;
const requestedHost = getRequestedHostedHost(req);
const hasPublicCookie = typeof publicHostedCookieToken === 'string' && !!publicHostedCookieToken;
let hasInvalidPublicCookie = false;
if (
typeof publicHostedCookieToken === 'string'
&& publicHostedCookieToken
&& typeof authService.verifyPublicHostedActorToken === 'function'
) {
try {
const claims = authService.verifyPublicHostedActorToken(publicHostedCookieToken, {
...(appUid ? { expectedAppUid: appUid } : {}),
expectedSubdomain: hostedSubdomain,
expectedHost: requestedHost,
});
return {
source: 'public-cookie',
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
tokenAppUid: claims.appUid || appUid,
subdomain: claims.subdomain || hostedSubdomain,
host: claims.host || requestedHost,
hasValidPublicCookie: true,
hasPublicCookie,
hasInvalidPublicCookie,
actor: null,
};
} catch (e) {
hasInvalidPublicCookie = true;
logPrivateAccessEvent('public_actor.identity_public_cookie_rejected', {
appUid: appUid ?? null,
requestHost: req.hostname,
reason: getPrivateAccessRejectionReason(e),
});
}
}
const sessionToken = req.cookies?.[cookieName];
if ( typeof sessionToken === 'string' && sessionToken ) {
try {
const actor = await authService.authenticate_from_token(sessionToken);
const identity = actorToPrivateIdentity(actor);
if ( identity ) {
return {
source: 'session-cookie',
...identity,
tokenAppUid: appUid,
subdomain: hostedSubdomain,
host: requestedHost,
hasValidPublicCookie: false,
hasPublicCookie,
hasInvalidPublicCookie,
actor,
};
}
} catch (e) {
logPrivateAccessEvent('public_actor.identity_session_cookie_rejected', {
appUid: appUid ?? null,
requestHost: req.hostname,
reason: getPrivateAccessRejectionReason(e),
});
}
}
const bootstrapToken = getBootstrapPrivateToken(req);
if ( typeof bootstrapToken === 'string' && bootstrapToken ) {
if ( typeof authService.resolvePrivateBootstrapIdentityFromToken === 'function' ) {
try {
const identity = await authService.resolvePrivateBootstrapIdentityFromToken(
bootstrapToken,
{
...(appUid ? { expectedAppUid: appUid } : {}),
},
);
if ( identity?.userUid ) {
return {
source: 'bootstrap-token',
...identity,
tokenAppUid: appUid,
subdomain: hostedSubdomain,
host: requestedHost,
hasValidPublicCookie: false,
hasPublicCookie,
hasInvalidPublicCookie,
actor: null,
};
}
} catch (e) {
logPrivateAccessEvent('public_actor.identity_bootstrap_rejected', {
appUid: appUid ?? null,
requestHost: req.hostname,
reason: getPrivateAccessRejectionReason(e),
});
}
} else {
try {
const actor = await authService.authenticate_from_token(bootstrapToken);
const identity = actorToPrivateIdentity(actor);
if ( identity ) {
return {
source: 'bootstrap-token',
...identity,
tokenAppUid: appUid,
subdomain: hostedSubdomain,
host: requestedHost,
hasValidPublicCookie: false,
hasPublicCookie,
hasInvalidPublicCookie,
actor,
};
}
} catch (e) {
logPrivateAccessEvent('public_actor.identity_bootstrap_rejected', {
appUid: appUid ?? null,
requestHost: req.hostname,
reason: getPrivateAccessRejectionReason(e),
});
}
}
}
return {
source: 'none',
userUid: undefined,
sessionUuid: undefined,
tokenAppUid: appUid,
subdomain: hostedSubdomain,
host: requestedHost,
hasValidPublicCookie: false,
hasPublicCookie,
hasInvalidPublicCookie,
actor: null,
};
}
async function evaluatePublicHostedActorContext ({
req,
res,
services,
appUid,
}) {
const existingActor = req.actor || Context.get('actor');
if ( existingActor ) {
const existingIdentity = actorToPrivateIdentity(existingActor);
if ( existingIdentity?.userUid ) {
return true;
}
}
const authService = services.get('auth');
const identity = await resolvePublicHostedIdentity({
req,
services,
appUid,
});
if ( identity.actor ) {
setHostedActorOnRequestContext({
req,
actor: identity.actor,
});
} else if ( identity.userUid ) {
const lightweightActor = buildLightweightHostedActor({
userUid: identity.userUid,
sessionUuid: identity.sessionUuid,
});
setHostedActorOnRequestContext({
req,
actor: lightweightActor,
});
}
if ( !identity.userUid || identity.hasValidPublicCookie ) {
return true;
}
let tokenAppUid = identity.tokenAppUid;
if ( !tokenAppUid && typeof authService.app_uid_from_origin === 'function' ) {
try {
const protocol = `${config.protocol ?? 'https'}`
.trim()
.replace(/:$/, '') || 'https';
tokenAppUid = await authService.app_uid_from_origin(`${protocol}://${req.hostname}`);
} catch {
tokenAppUid = undefined;
}
}
if ( !tokenAppUid || typeof authService.createPublicHostedActorToken !== 'function' ) {
return true;
}
try {
const publicHostedActorToken = authService.createPublicHostedActorToken({
appUid: tokenAppUid,
userUid: identity.userUid,
sessionUuid: identity.sessionUuid,
subdomain: identity.subdomain,
host: identity.host,
});
res.cookie(
getPublicHostedActorCookieName(authService),
publicHostedActorToken,
typeof authService.getPublicHostedActorCookieOptions === 'function'
? authService.getPublicHostedActorCookieOptions({
requestHostname: req.hostname,
})
: undefined,
);
} catch (e) {
logPrivateAccessEvent('public_actor.cookie_set_failed', {
appUid: tokenAppUid ?? null,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
reason: getPrivateAccessRejectionReason(e),
});
return true;
}
const sanitizedUrl = stripBootstrapAuthTokenFromOriginalUrl(req.originalUrl);
if ( sanitizedUrl ) {
logPrivateAccessEvent('public_actor.cookie_redirect', {
appUid: tokenAppUid ?? null,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
redirectUrl: sanitizedUrl,
});
res.redirect(sanitizedUrl);
return false;
}
return true;
}
function escapeHtml (value) {
const raw = `${value ?? ''}`;
return raw
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll('\'', ''');
}
function respondPrivateLoginBootstrap ({ res, app }) {
const appName =
typeof app?.name === 'string' && app.name.trim()
? app.name.trim()
: 'this app';
const appTitle = typeof app?.title === 'string' && app.title.trim()
? app.title.trim()
: appName;
const appDescription = typeof app?.description === 'string' && app.description.trim()
? app.description.trim()
: `${appTitle} requires Puter authentication before private files can load.`;
const appIcon = typeof app?.icon === 'string' && app.icon.trim()
? app.icon.trim()
: null;
const marketplaceAppUrl = getMarketplaceAppUrl(app);
const safeAppName = escapeHtml(appName);
const safeAppTitle = escapeHtml(appTitle);
const safeAppDescription = escapeHtml(appDescription);
const safeMarketplaceAppUrl = escapeHtml(marketplaceAppUrl ?? '');
const safeAppIcon = escapeHtml(appIcon ?? '');
const loginHtml = dedent(`
Sign In Required | ${safeAppTitle}
${safeMarketplaceAppUrl ? ` ` : ''}
${safeAppIcon ? ` ` : ''}
${safeAppIcon ? ` ` : ''}
${safeMarketplaceAppUrl ? ` ` : ''}
Sign in required
${safeAppName} requires Puter authentication before private files can load.
Click “Sign In with Puter” to continue.
Sign In with Puter
Retry
`);
res.status(200);
res.set('Cache-Control', 'no-store');
res.set('X-Robots-Tag', 'noindex, nofollow');
setReferrerPolicyHeader(res);
appendLinkHeader(
res,
marketplaceAppUrl ? `<${marketplaceAppUrl}>; rel="canonical"` : null,
);
res.set('Content-Type', 'text/html; charset=UTF-8');
return res.send(loginHtml);
}
async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath }) {
const identity = await resolvePrivateIdentity({
req,
services,
appUid: app.uid,
});
if ( ! identity.userUid ) {
logPrivateAccessEvent('private_access.auth_required', {
appUid: app.uid,
userUid: null,
requestHost: req.hostname,
requestPath,
source: identity.source,
hasPrivateCookie: identity.hasPrivateCookie,
hasInvalidPrivateCookie: identity.hasInvalidPrivateCookie,
});
respondPrivateLoginBootstrap({ res, app });
return false;
}
const eventService = services.get('event');
const accessCheckEvent = {
appUid: app.uid,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
requestPath,
result: {
allowed: false,
},
};
try {
await eventService.emit('app.privateAccess.check', accessCheckEvent);
} catch (e) {
logPrivateAccessEvent('private_access.entitlement_check_error', {
appUid: app.uid,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
requestPath,
source: identity.source,
error: e?.message || String(e),
});
console.error('private app access check failed', e);
}
if ( ! accessCheckEvent.result.allowed ) {
const redirectUrl = getPrivateDeniedRedirectUrl(
app,
accessCheckEvent.result.redirectUrl,
);
logPrivateAccessEvent('private_access.denied', {
appUid: app.uid,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
requestPath,
source: identity.source,
reason: accessCheckEvent.result.reason ?? null,
redirectUrl,
hasPrivateCookie: identity.hasPrivateCookie,
hasInvalidPrivateCookie: identity.hasInvalidPrivateCookie,
});
const marketplaceAppUrl = getMarketplaceAppUrl(app);
appendLinkHeader(
res,
marketplaceAppUrl ? `<${marketplaceAppUrl}>; rel="alternate"` : null,
);
res.redirect(redirectUrl);
return false;
}
const shouldRefreshPrivateCookie = identity.userUid && !identity.hasValidPrivateCookie;
if ( identity.userUid && !identity.hasValidPrivateCookie ) {
const authService = services.get('auth');
const privateToken = authService.createPrivateAssetToken({
appUid: identity.tokenAppUid || app.uid,
userUid: identity.userUid,
sessionUuid: identity.sessionUuid,
subdomain: identity.subdomain,
privateHost: identity.privateHost,
});
res.cookie(
authService.getPrivateAssetCookieName(),
privateToken,
authService.getPrivateAssetCookieOptions({
requestHostname: req.hostname,
}),
);
const sanitizedUrl = stripBootstrapAuthTokenFromOriginalUrl(req.originalUrl);
const shouldKeepBootstrapTokenInUrl = hasAppInstanceIdQueryParam(req);
if ( sanitizedUrl && !shouldKeepBootstrapTokenInUrl ) {
logPrivateAccessEvent('private_access.allowed_cookie_redirect', {
appUid: app.uid,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
requestPath,
source: identity.source,
redirectUrl: sanitizedUrl,
});
res.redirect(sanitizedUrl);
return false;
}
if ( sanitizedUrl && shouldKeepBootstrapTokenInUrl ) {
logPrivateAccessEvent('private_access.allowed_cookie_redirect_skipped_for_app_instance', {
appUid: app.uid,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
requestPath,
source: identity.source,
redirectUrl: sanitizedUrl,
});
}
}
logPrivateAccessEvent('private_access.allowed', {
appUid: app.uid,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
requestPath,
source: identity.source,
cookieRefreshed: !!shouldRefreshPrivateCookie,
hasPrivateCookie: identity.hasPrivateCookie,
hasInvalidPrivateCookie: identity.hasInvalidPrivateCookie,
});
return true;
}
async function runInternal (req, res, next) {
const isPrivateHostedRequest = hostMatchesPrivateDomain(req.hostname);
const subdomain =
req.is_custom_domain && !isPrivateHostedRequest ? req.hostname :
req.subdomains[0] === 'devtest' ? 'devtest' :
getSubdomainFromHostedRequest(req);
let path = (req.baseUrl + req.path) || 'index.html';
const context = Context.get();
const services = context.get('services');
const getUsernameSite = (async () => {
if ( ! subdomain.endsWith('.at') ) return;
const parts = subdomain.split('.');
if ( parts.length !== 2 ) return;
const username = parts[0];
if ( ! username.match(usernameRegex) ) {
return;
}
const filesystemService = services.get('filesystem');
const indexNode = await filesystemService.node(new NodePathSelector(`/${username}/Public/index.html`));
const node = await filesystemService.node(new NodePathSelector(`/${username}/Public`));
if ( ! await indexNode.exists() ) return;
return {
name: `${username }.at`,
uuid: uuidv5(username, AT_DIRECTORY_NAMESPACE),
root_dir_id: await node.get('mysql-id'),
};
});
if ( req.hostname === staticHostingDomain || req.hostname === staticHostingDomainAlt || subdomain === 'www' ) {
// redirect to information page about static hosting
return res.redirect(staticHostingBaseDomainRedirect);
}
const site =
await getUsernameSite() ||
await (async () => {
const puterSiteService = services.get('puter-site');
const site = await puterSiteService.get_subdomain(subdomain, {
is_custom_domain: req.is_custom_domain && !isPrivateHostedRequest,
});
return site;
})();
if ( site === null ) {
return res.status(404).send('Subdomain not found');
}
const subdomainOwner = await get_user({ id: site.user_id });
if ( subdomainOwner?.suspended ) {
// This used to be "401 Account suspended", but this implies
// the client user is suspended, which is not the case.
// Instead we simply return 404, indicating that this page
// doesn't exist without further specifying that the owner's
// account is suspended. (the client user doesn't need to know)
return res.status(404).send('Subdomain not found');
}
const associatedApp = site.associated_app_id
? await get_app({ id: site.associated_app_id })
: null;
const privateApp = await resolvePrivateAppForHostedSite({
req,
site,
services,
associatedApp,
});
const privateAppEnabled = isPrivateApp(privateApp);
const privateAccessGateEnabled = isPrivateAccessGateEnabled();
if ( privateAppEnabled ) {
setReferrerPolicyHeader(res);
}
if (
privateAccessGateEnabled
&& privateAppEnabled
&& !hostMatchesPrivateDomain(req.hostname)
) {
const privateHostRedirect = buildPrivateHostRedirectUrl(req, privateApp);
if ( privateHostRedirect ) {
logPrivateAccessEvent('private_access.host_redirect', {
appUid: privateApp?.uid ?? null,
requestHost: req.hostname,
requestPath: req.path,
redirectUrl: privateHostRedirect,
});
const marketplaceAppUrl = getMarketplaceAppUrl(privateApp);
appendLinkHeader(
res,
marketplaceAppUrl
? `<${marketplaceAppUrl}>; rel="alternate"`
: null,
);
return res.redirect(privateHostRedirect);
}
logPrivateAccessEvent('private_access.host_mismatch_denied', {
appUid: privateApp?.uid ?? null,
requestHost: req.hostname,
requestPath: req.path,
});
return res.status(403).send('Private app host mismatch');
}
if (
site.associated_app_id &&
!privateAppEnabled &&
!req.query['puter.app_instance_id'] &&
( path === '' || path.endsWith('/') )
) {
const app = associatedApp || await get_app({ id: site.associated_app_id });
return res.redirect(`${originUrl}/app/${app.name}/`);
}
if ( path === '' ) path += '/index.html';
else if ( path.endsWith('/') ) path += 'index.html';
const resolvedUrlPath =
resolve('/', path);
const filesystemService = services.get('filesystem');
let subdomainRootPath = '';
if ( site.root_dir_id !== null && site.root_dir_id !== undefined ) {
const node = await filesystemService.node(new NodeInternalIDSelector('mysql', site.root_dir_id));
if ( ! await node.exists() ) {
return res.status(502).send('subdomain is pointing to deleted directory');
}
if ( await node.get('type') !== TYPE_DIRECTORY ) {
return res.status(502).send('subdomain is pointing to non-directory');
}
// Verify subdomain owner permission
const subdomainActor = Actor.adapt(subdomainOwner);
const aclService = services.get('acl');
if ( ! await aclService.check(subdomainActor, node, 'read') ) {
res.status(502).send('subdomain owner does not have access to directory');
return;
}
subdomainRootPath = await node.get('path');
}
if ( ! subdomainRootPath ) {
return respondHtmlError({
html: dedent(`
Subdomain or site is not pointing to a directory.
`),
}, req, res, next);
}
if ( !subdomainRootPath || subdomainRootPath === '/' ) {
throw APIError.create('forbidden');
}
req.__puterSiteRootPath = subdomainRootPath;
if ( ! privateAppEnabled ) {
try {
const actorContextReady = await evaluatePublicHostedActorContext({
req,
res,
services,
appUid: privateApp?.uid || associatedApp?.uid,
});
if ( ! actorContextReady ) return;
} catch (e) {
logPrivateAccessEvent('public_actor.evaluate_failed', {
appUid: privateApp?.uid || associatedApp?.uid || null,
requestHost: req.hostname,
reason: getPrivateAccessRejectionReason(e),
});
}
}
if ( privateAccessGateEnabled && privateAppEnabled ) {
const accessAllowed = await evaluatePrivateAppAccess({
req,
res,
services,
app: privateApp,
requestPath: req.path,
});
if ( ! accessAllowed ) return;
}
const filepath = subdomainRootPath + decodeURIComponent(resolvedUrlPath);
const targetNode = await filesystemService.node(new NodePathSelector(filepath));
await targetNode.fetchEntry();
if ( ! await targetNode.exists() ) {
return await respond404({ path }, req, res, next, subdomainRootPath);
}
const targetIsDir = await targetNode.get('type') === TYPE_DIRECTORY;
if ( targetIsDir && !resolvedUrlPath.endsWith('/') ) {
return res.redirect(`${resolvedUrlPath }/`);
}
if ( targetIsDir ) {
return await respond404({ path }, req, res, next, subdomainRootPath);
}
const contentType = contentTypeFromMime(await targetNode.get('name'));
res.set('Content-Type', contentType);
const aclConfig = {
no_acl: true,
actor: null,
};
if ( site.protected ) {
const authService = req.services.get('auth');
const getSiteActorFromToken = async () => {
const siteToken = req.cookies['puter.site.token'];
if ( ! siteToken ) return;
let failed = false;
let siteActor;
try {
siteActor =
await authService.authenticate_from_token(siteToken);
} catch (e) {
failed = true;
}
if ( failed ) return;
if ( ! siteActor ) return;
// security measure: if 'puter.site.token' is set
// to a different actor type, someone is likely
// trying to exploit the system.
if ( ! (siteActor.type instanceof SiteActorType) ) {
return;
}
aclConfig.actor = siteActor;
// Refresh the token if it's been 30 seconds since
// the last request
if (
(Date.now() - siteActor.type.iat * 1000)
>
1000 * 30
) {
const siteToken = authService.get_site_app_token({
site_uid: site.uuid,
});
res.cookie('puter.site.token', siteToken);
}
return true;
};
const makeSiteActorFromAppToken = async () => {
const token = req.query['puter.auth.token'];
aclConfig.no_acl = false;
if ( ! token ) {
const e = APIError.create('token_missing');
return respondError({ req, res, e });
}
const appActor =
await authService.authenticate_from_token(token);
const userActor =
appActor.get_related_actor(UserActorType);
const permissionService = req.services.get('permission');
const perm = await (async () => {
if ( userActor.type.user.id === site.user_id ) {
return {};
}
const reading = await permissionService.scan(userActor, `site:uid#${site.uuid}:access`);
const options = PermissionUtil.reading_to_options(reading);
return options.length > 0;
})();
if ( ! perm ) {
const e = APIError.create('forbidden');
respondError({ req, res, e });
return false;
}
const siteActor = await Actor.create(SiteActorType, { site });
aclConfig.actor = siteActor;
// This subdomain is allowed to keep the site actor token,
// so we send it here as a cookie so other html files can
// also load.
const siteToken = authService.get_site_app_token({
site_uid: site.uuid,
});
res.cookie('puter.site.token', siteToken);
return true;
};
let ok = await getSiteActorFromToken();
if ( ! ok ) {
ok = await makeSiteActorFromAppToken();
}
if ( ! ok ) return;
Object.freeze(aclConfig);
}
// Helper function to parse Range header
const parseRangeHeader = (rangeHeader) => {
// Check if this is a multipart range request
if ( rangeHeader.includes(',') ) {
// For now, we'll only serve the first range in multipart requests
// as the underlying storage layer doesn't support multipart responses
const firstRange = rangeHeader.split(',')[0].trim();
const matches = firstRange.match(/bytes=(\d+)-(\d*)/);
if ( ! matches ) return null;
const start = parseInt(matches[1], 10);
const end = matches[2] ? parseInt(matches[2], 10) : null;
return { start, end, isMultipart: true };
}
// Single range request
const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if ( ! matches ) return null;
const start = parseInt(matches[1], 10);
const end = matches[2] ? parseInt(matches[2], 10) : null;
return { start, end, isMultipart: false };
};
if ( req.headers['range'] ) {
res.status(206);
// Parse the Range header and set Content-Range
const rangeInfo = parseRangeHeader(req.headers['range']);
if ( rangeInfo ) {
const { start, end, isMultipart } = rangeInfo;
// For open-ended ranges, we need to calculate the actual end byte
let actualEnd = end;
let fileSize = null;
try {
fileSize = await targetNode.get('size');
if ( end === null ) {
actualEnd = fileSize - 1; // File size is 1-based, end byte is 0-based
}
} catch (e) {
// If we can't get file size, we'll let the storage layer handle it
// and not set Content-Range header
actualEnd = null;
fileSize = null;
}
if ( actualEnd !== null ) {
const totalSize = fileSize !== null ? fileSize : '*';
const contentRange = `bytes ${start}-${actualEnd}/${totalSize}`;
res.set('Content-Range', contentRange);
}
// If this was a multipart request, modify the range header to only include the first range
if ( isMultipart ) {
req.headers['range'] = end !== null
? `bytes=${start}-${end}`
: `bytes=${start}-`;
}
}
} else {
if ( targetNode.entry.size ) {
res.set('x-expected-entity-length', targetNode.entry.size);
}
}
res.set({ 'Accept-Ranges': 'bytes' });
const llRead = new LLRead();
// const actor = Actor.adapt(req.user);
const stream = await llRead.run({
no_acl: aclConfig.no_acl,
actor: aclConfig.actor,
fsNode: targetNode,
...(req.headers['range'] ? { range: req.headers['range'] } : { }),
});
// Destroy the stream if the client disconnects
req.on('close', () => {
stream.destroy();
});
try {
return stream.pipe(res);
} catch (e) {
const handled = await respondSiteError({
path,
req,
res,
next,
subdomainRootPath,
});
if ( handled ) return;
return res.status(500).send(`Error reading file: ${ e.message}`);
}
}
async function respondSiteError ({ path, html, req, res, next, subdomainRootPath }) {
const handled = await maybeRespondWithSiteConfig({
path,
html,
req,
res,
next,
subdomainRootPath,
errorStatus: 500,
});
return handled;
}
async function getSiteErrorConfig (req, subdomainRootPath) {
if ( ! subdomainRootPath ) return null;
req.__puterSiteErrorConfigCache ??= Object.create(null);
if ( req.__puterSiteErrorConfigCache[subdomainRootPath] !== undefined ) {
return req.__puterSiteErrorConfigCache[subdomainRootPath];
}
try {
const context = Context.get();
const services = context.get('services');
const filesystemService = services.get('filesystem');
const configPath = `${subdomainRootPath}/${puterSiteConfigFilename}`;
const configNode = await filesystemService.node(new NodePathSelector(configPath));
await configNode.fetchEntry();
if ( ! await configNode.exists() ) {
req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
return null;
}
if ( await configNode.get('type') === TYPE_DIRECTORY ) {
req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
return null;
}
const size = Number(await configNode.get('size') ?? 0);
if ( Number.isFinite(size) && size > puterSiteConfigMaxSize ) {
req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
return null;
}
const llRead = new LLRead();
const stream = await llRead.run({
no_acl: true,
actor: null,
fsNode: configNode,
});
const buffer = await streamToBuffer(stream);
const text = buffer.toString('utf8');
const parsed = parseSiteErrorConfig(text);
req.__puterSiteErrorConfigCache[subdomainRootPath] = parsed;
return parsed;
} catch {
req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
return null;
}
}
async function getSiteFileNode (subdomainRootPath, sitePath) {
const context = Context.get();
const services = context.get('services');
const filesystemService = services.get('filesystem');
const fullPath = `${subdomainRootPath}${sitePath}`;
const node = await filesystemService.node(new NodePathSelector(fullPath));
await node.fetchEntry();
if ( ! await node.exists() ) return null;
if ( await node.get('type') === TYPE_DIRECTORY ) return null;
return node;
}
async function maybeRespondWithSiteConfig ({
path,
html,
req,
res,
next,
subdomainRootPath,
errorStatus,
}) {
if ( ! subdomainRootPath ) return false;
const parsedConfig = await getSiteErrorConfig(req, subdomainRootPath);
if ( ! parsedConfig ) return false;
const rule = getSiteErrorRule(parsedConfig, errorStatus);
if ( ! rule ) return false;
const responseStatus = rule.status ?? errorStatus;
if ( rule.file ) {
const node = await getSiteFileNode(subdomainRootPath, rule.file);
if ( node ) {
await streamSiteFile({
req,
res,
fsNode: node,
status: responseStatus,
});
return true;
}
}
if ( rule.status !== null && rule.status !== undefined ) {
respondHtmlError({ path, html, status: responseStatus }, req, res, next);
return true;
}
return false;
}
async function streamSiteFile ({ req, res, fsNode, status }) {
res.status(status);
const contentType =
contentTypeFromMime(await fsNode.get('name')) ||
'application/octet-stream';
res.set('Content-Type', contentType);
const llRead = new LLRead();
const stream = await llRead.run({
no_acl: true,
actor: null,
fsNode,
});
req.on('close', () => {
stream.destroy();
});
return stream.pipe(res);
}
async function respond404 ({ path, html }, req, res, next, subdomainRootPath) {
const handled = await maybeRespondWithSiteConfig({
path,
html,
req,
res,
next,
subdomainRootPath,
errorStatus: 404,
});
if ( handled ) return;
if ( subdomainRootPath ) {
const custom404Node = await getSiteFileNode(subdomainRootPath, '/404.html');
if ( custom404Node ) {
return streamSiteFile({
req,
res,
fsNode: custom404Node,
status: 404,
});
}
}
return respondHtmlError({ path, html, status: 404 }, req, res, next);
}
function respondHtmlError ({ path, html, status = 404 }, req, res, _next) {
res.status(status);
res.set('Content-Type', 'text/html; charset=UTF-8');
res.write(``);
res.write(`
${status} `);
res.write('
');
if ( status === 404 && path ) {
if ( path === '/index.html' ) {
res.write('index.html Not Found');
} else {
res.write('Not Found');
}
} else {
res.write(html || 'Request failed');
}
res.write('
');
res.write('
');
return res.end();
}
function respondError ({ req, res, e }) {
if ( ! (e instanceof APIError) ) {
// TODO: alarm here
e = APIError.create('unknown_error');
}
res.redirect(`${originUrl}?${e.querystringize({
...(req.query['puter.app_instance_id'] ? {
'error_from_within_iframe': true,
} : {}),
})}`);
}
export async function puterSiteMiddleware (req, res, next) {
const isSubdomain =
req.hostname.endsWith(staticHostingDomain)
|| (staticHostingDomainAlt && req.hostname.endsWith(staticHostingDomainAlt))
|| hostMatchesPrivateDomain(req.hostname)
|| req.subdomains[0] === 'devtest'
;
if ( !isSubdomain && !req.is_custom_domain ) return next();
res.setHeader('Access-Control-Allow-Origin', '*');
try {
const expectedCtx = req.ctx;
const receivedCtx = Context.get();
if ( expectedCtx && !receivedCtx ) {
await expectedCtx.arun(async () => {
await runInternal(req, res, next);
});
} else await runInternal(req, res, next);
} catch ( e ) {
console.error('puter-site middleware error', e);
if ( !res.headersSent && req.__puterSiteRootPath ) {
try {
const handled = await respondSiteError({
path: req.path,
req,
res,
next,
subdomainRootPath: req.__puterSiteRootPath,
});
if ( handled ) return;
} catch ( siteError ) {
console.error('failed handling site error response', siteError);
}
}
api_error_handler(e, req, res, next);
}
}
================================================
FILE: src/backend/src/routers/hosting/puterSiteMiddleware.test.js
================================================
/*
* Copyright (C) 2026-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 .
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { puterSiteMiddleware } from './puterSiteMiddleware';
import config from '../../config.js';
import { Context } from '../../util/context.js';
// Mocks to test middleware logic with minimal integration complexity
// (I added region markers, so this can be collapsed for readability)
// #region: mocks
let getUserMockImpl = async () => null;
let getAppMockImpl = async () => null;
vi.mock('../../config.js', () => ({
default: {
static_hosting_domain: 'site.puter.localhost',
static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
private_app_hosting_domain: 'puter.dev',
private_app_hosting_domain_alt: 'puter.dev',
enable_private_app_access_gate: true,
origin: 'https://puter.com',
cookie_name: 'puter.session.token',
username_regex: /^[a-z0-9_]+$/,
},
static_hosting_domain: 'site.puter.localhost',
static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
private_app_hosting_domain: 'puter.dev',
private_app_hosting_domain_alt: 'puter.dev',
enable_private_app_access_gate: true,
origin: 'https://puter.com',
cookie_name: 'puter.session.token',
username_regex: /^[a-z0-9_]+$/,
}));
vi.mock('../../modules/web/lib/api_error_handler.js', () => ({
default: vi.fn(),
}));
vi.mock('../../helpers.js', () => ({
get_user: vi.fn((...args) => getUserMockImpl(...args)),
get_app: vi.fn((...args) => getAppMockImpl(...args)),
}));
vi.mock('../../util/context.js', () => ({
Context: {
get: vi.fn(),
set: vi.fn(),
},
}));
// Mock Context to allow arun passthrough
const mockContextInstance = {
get: vi.fn(),
arun: vi.fn().mockImplementation(async (fn) => await fn()),
};
vi.mock('../../filesystem/node/selectors.js', () => ({
default: {
NodeInternalIDSelector: class {
},
NodePathSelector: class {
},
},
NodeInternalIDSelector: class {
},
NodePathSelector: class {
},
}));
vi.mock('../../filesystem/FSNodeContext.js', () => ({
default: {
TYPE_DIRECTORY: 'directory',
},
TYPE_DIRECTORY: 'directory',
}));
vi.mock('../../filesystem/ll_operations/ll_read.js', () => ({
default: {
LLRead: class {
},
},
LLRead: class {
},
}));
vi.mock('../../services/auth/Actor.js', () => {
const adapt = vi.fn();
const create = vi.fn();
class UserActorType {
constructor ({ user, session, hasHttpOnlyCookie } = {}) {
this.user = user;
this.session = session;
this.hasHttpOnlyCookie = hasHttpOnlyCookie;
}
}
class SiteActorType {
}
class Actor {
constructor ({ user_uid, app_uid, type } = {}) {
this.user_uid = user_uid;
this.app_uid = app_uid;
this.type = type;
}
get_related_actor (actorType) {
if ( this.type instanceof actorType ) {
return this;
}
throw new Error('related_actor_not_found');
}
}
Actor.adapt = adapt;
Actor.create = create;
return {
Actor,
UserActorType,
SiteActorType,
};
});
vi.mock('../../api/APIError.js', () => ({
default: class APIError {
static create () {
return new this();
}
},
}));
vi.mock('../../services/auth/permissionUtils.mjs', () => ({
PermissionUtil: {
reading_to_options: vi.fn().mockReturnValue([]),
},
}));
vi.mock('dedent', () => ({
default: (str) => str,
}));
// #endregion
// Now import the module under test - this will use our mocks
describe('PuterSiteMiddleware', () => {
describe('base domain redirect', () => {
let capturedMiddleware;
beforeEach(() => {
vi.clearAllMocks();
config.enable_private_app_access_gate = true;
config.private_app_hosting_domain = 'puter.dev';
config.private_app_hosting_domain_alt = 'puter.dev';
Context.get = vi.fn().mockImplementation((key) => {
if ( key === 'actor' ) return undefined;
return mockContextInstance;
});
Context.set = vi.fn();
getUserMockImpl = async () => null;
getAppMockImpl = async () => null;
capturedMiddleware = puterSiteMiddleware;
});
/**
* Creates a mock request for static hosting domain
*/
const createMockRequest = (subdomain) => {
const hostname = subdomain
? `${subdomain}.${config.static_hosting_domain}`
: config.static_hosting_domain;
return {
hostname,
subdomains: subdomain ? [subdomain] : [],
is_custom_domain: false,
baseUrl: '',
path: '/',
ctx: mockContextInstance,
};
};
it('should redirect to info page when subdomain is empty (bare domain)', async () => {
const mockReq = createMockRequest('');
const mockRes = {
redirect: vi.fn(),
setHeader: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
expect(mockNext).not.toHaveBeenCalled();
});
it('should redirect to info page when subdomain is www', async () => {
const mockReq = createMockRequest('www');
const mockRes = {
redirect: vi.fn(),
setHeader: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
expect(mockNext).not.toHaveBeenCalled();
});
it('should NOT redirect when subdomain is a valid site name', async () => {
// Setup mock services for the "site not found" path
const mockServices = {
get: vi.fn().mockImplementation((svc) => {
if ( svc === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue(null),
};
}
if ( svc === 'filesystem' ) {
return {
node: vi.fn().mockResolvedValue({
exists: vi.fn().mockResolvedValue(false),
}),
};
}
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
const mockReq = createMockRequest('mysite');
const mockRes = {
redirect: vi.fn(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
// The middleware will error out further down (due to incomplete mocks)
// but the important thing is: did it try to redirect to the info page?
try {
await capturedMiddleware(mockReq, mockRes, mockNext);
} catch (e) {
// Expected - incomplete mocks cause errors after the redirect check
}
// The key assertion: it should NOT have redirected to the info page
// because 'mysite' is a valid subdomain, not '' or 'www'
expect(mockRes.redirect).not.toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
});
it('should use exactly the URL from config (not hardcoded)', async () => {
// This test verifies the middleware reads from config.static_hosting_base_domain_redirect
// If someone hardcodes a different URL, this assertion will catch that the
// redirect URL matches what is in the mocked config.
const mockReq = createMockRequest('');
const mockRes = {
redirect: vi.fn(),
setHeader: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
// Verify it uses the exact URL from the mocked config
expect(mockRes.redirect).toHaveBeenCalledWith(config.static_hosting_base_domain_redirect);
});
});
describe('private app access gate', () => {
let capturedMiddleware;
beforeEach(() => {
vi.clearAllMocks();
config.enable_private_app_access_gate = true;
Context.get = vi.fn().mockImplementation((key) => {
if ( key === 'actor' ) return undefined;
return mockContextInstance;
});
Context.set = vi.fn();
getUserMockImpl = async () => null;
getAppMockImpl = async () => null;
capturedMiddleware = puterSiteMiddleware;
});
it('redirects private app assets to puter.dev host even before index_url migration', async () => {
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: null,
}),
};
}
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.site.puter.localhost/',
});
const mockReq = {
hostname: 'paid.site.puter.localhost',
subdomains: ['paid'],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js?foo=1',
query: {},
cookies: {},
headers: {},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(mockRes.redirect).toHaveBeenCalledWith('https://paid.puter.dev/asset.js?foo=1');
expect(mockNext).not.toHaveBeenCalled();
});
it('accepts private app host matching the configured alt private domain', async () => {
config.private_app_hosting_domain = 'app.puter.localhost:4100';
config.private_app_hosting_domain_alt = 'puter.dev';
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockResolvedValue({
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: ['paid'],
is_custom_domain: false,
baseUrl: '',
path: '/index.html',
originalUrl: '/index.html',
query: {},
cookies: {},
headers: {},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
set: vi.fn().mockReturnThis(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(mockRes.redirect).not.toHaveBeenCalledWith(
expect.stringContaining('app.puter.localhost:4100'),
);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('Sign in required'));
expect(mockNext).not.toHaveBeenCalled();
});
it('serves login bootstrap html when private app identity is missing', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
event.result.redirectUrl = 'https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111';
});
const dbRead = vi.fn().mockResolvedValue([
{
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
owner_user_id: 101,
},
]);
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: null,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockResolvedValue({
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'database' ) {
return {
get: vi.fn().mockReturnValue({
read: dbRead,
}),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/index.html',
originalUrl: '/index.html',
cookies: {},
headers: {},
query: {},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
set: vi.fn().mockReturnThis(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(eventEmit).not.toHaveBeenCalled();
expect(dbRead).toHaveBeenCalledWith(
expect.stringContaining('index_url IN'),
expect.arrayContaining([
101,
'https://paid.puter.dev',
'https://paid.puter.dev/',
'https://paid.puter.dev/index.html',
'https://paid.site.puter.localhost',
'https://paid.site.puter.localhost/',
'https://paid.site.puter.localhost/index.html',
]),
);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('https://js.puter.com/v2/'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('puter.auth.signIn()'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('localStorage.getItem(\'auth_token\')'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('tryStoredTokenBootstrap'));
expect(mockRes.set).toHaveBeenCalledWith('Referrer-Policy', 'no-referrer');
expect(mockRes.redirect).not.toHaveBeenCalled();
expect(mockRes.cookie).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});
it('does not redirect private root requests to puter.com app route before access bootstrap', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
event.result.redirectUrl = 'https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111';
});
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockResolvedValue({
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.site.puter.localhost/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/',
originalUrl: '/?puter.auth.token=abc',
cookies: {},
headers: {},
query: {
'puter.auth.token': 'abc',
},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
set: vi.fn().mockReturnThis(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(mockRes.redirect).not.toHaveBeenCalledWith('https://puter.com/app/paid-app/');
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('https://js.puter.com/v2/'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('puter.auth.signIn()'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('meta property="og:title"'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('/app/paid-app/'));
expect(mockNext).not.toHaveBeenCalled();
});
it('denies private app access and redirects using entitlement response', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
event.result.redirectUrl = 'https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111';
});
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
app_uid_from_origin: vi.fn().mockResolvedValue('app-origin-111'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockResolvedValue({
type: {},
get_related_actor: vi.fn().mockReturnValue({
type: {
user: { uuid: 'user-111' },
session: 'session-111',
},
}),
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockResolvedValue({
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/index.html',
originalUrl: '/index.html',
cookies: {
'puter.session.token': 'session-token',
},
headers: {},
query: {},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(eventEmit).toHaveBeenCalledWith(
'app.privateAccess.check',
expect.objectContaining({
appUid: 'app-11111111-1111-1111-1111-111111111111',
userUid: 'user-111',
}),
);
expect(mockRes.redirect).toHaveBeenCalledWith('https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111');
expect(mockRes.cookie).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});
it('uses bootstrap fallback identity when strict bootstrap auth fails', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
event.result.redirectUrl = 'https://apps.puter.com/app/paid-app';
});
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockImplementation(() => {
throw new Error('token_auth_failed');
}),
resolvePrivateBootstrapIdentityFromToken: vi.fn().mockResolvedValue({
userUid: 'user-111',
sessionUuid: 'session-111',
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockResolvedValue({
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/index.html',
originalUrl: '/index.html?puter.auth.token=bootstrap-token',
cookies: {},
headers: {},
query: {
'puter.auth.token': 'bootstrap-token',
},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
expect(authService.resolvePrivateBootstrapIdentityFromToken)
.toHaveBeenCalledWith('bootstrap-token', {
expectedAppUids: ['app-11111111-1111-1111-1111-111111111111'],
});
expect(eventEmit).toHaveBeenCalledWith(
'app.privateAccess.check',
expect.objectContaining({
appUid: 'app-11111111-1111-1111-1111-111111111111',
userUid: 'user-111',
}),
);
expect(mockRes.redirect).toHaveBeenCalledWith('https://apps.puter.com/app/paid-app');
expect(mockRes.send).not.toHaveBeenCalled();
expect(mockRes.cookie).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});
it('passes request hostname to private asset cookie options on allow', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = true;
});
const rootDirectoryNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
};
const missingFileNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(false),
get: vi.fn().mockResolvedValue(null),
};
let filesystemNodeCallCount = 0;
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
app_uid_from_origin: vi.fn().mockResolvedValue('app-origin-111'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockResolvedValue({
type: {},
get_related_actor: vi.fn().mockReturnValue({
type: {
user: { uuid: 'user-allow-111' },
session: 'session-allow-111',
},
}),
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({ sameSite: 'none' }),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js',
cookies: {
'puter.session.token': 'session-token',
},
headers: {},
query: {},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.getPrivateAssetCookieOptions).toHaveBeenCalledWith({
requestHostname: 'paid.puter.dev',
});
expect(authService.createPrivateAssetToken).toHaveBeenCalledWith({
appUid: 'app-origin-111',
userUid: 'user-allow-111',
sessionUuid: 'session-allow-111',
subdomain: 'paid',
privateHost: 'paid.puter.dev',
});
expect(mockRes.cookie).toHaveBeenCalledWith(
'puter.private.asset.token',
'private-token',
{ sameSite: 'none' },
);
expect(mockNext).not.toHaveBeenCalled();
});
it('includes subdomain and private host when strict bootstrap token auth succeeds', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = true;
});
const rootDirectoryNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
};
const missingFileNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(false),
get: vi.fn().mockResolvedValue(null),
};
let filesystemNodeCallCount = 0;
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockResolvedValue({
type: {},
get_related_actor: vi.fn().mockReturnValue({
type: {
user: { uuid: 'user-bootstrap-111' },
session: 'session-bootstrap-111',
},
}),
}),
resolvePrivateBootstrapIdentityFromToken: vi.fn().mockResolvedValue({
userUid: 'user-bootstrap-111',
sessionUuid: 'session-bootstrap-111',
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({ sameSite: 'none' }),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js?puter.auth.token=bootstrap-token&foo=bar',
cookies: {},
headers: {},
query: {
'puter.auth.token': 'bootstrap-token',
foo: 'bar',
},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
expect(authService.resolvePrivateBootstrapIdentityFromToken).toHaveBeenCalledWith('bootstrap-token', {
expectedAppUids: ['app-11111111-1111-1111-1111-111111111111'],
});
expect(authService.createPrivateAssetToken).toHaveBeenCalledWith({
appUid: 'app-11111111-1111-1111-1111-111111111111',
userUid: 'user-bootstrap-111',
sessionUuid: 'session-bootstrap-111',
subdomain: 'paid',
privateHost: 'paid.puter.dev',
});
expect(authService.getPrivateAssetCookieOptions).toHaveBeenCalledWith({
requestHostname: 'paid.puter.dev',
});
expect(mockRes.cookie).toHaveBeenCalledWith(
'puter.private.asset.token',
'private-token',
{ sameSite: 'none' },
);
expect(mockRes.redirect).toHaveBeenCalledWith('/asset.js?foo=bar');
expect(mockNext).not.toHaveBeenCalled();
});
it('does not server-redirect bootstrap token for iframe app instance requests', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = true;
});
const rootDirectoryNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
};
const missingFileNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(false),
get: vi.fn().mockResolvedValue(null),
};
let filesystemNodeCallCount = 0;
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockResolvedValue({
type: {},
get_related_actor: vi.fn().mockReturnValue({
type: {
user: { uuid: 'user-bootstrap-111' },
session: 'session-bootstrap-111',
},
}),
}),
resolvePrivateBootstrapIdentityFromToken: vi.fn().mockResolvedValue({
userUid: 'user-bootstrap-111',
sessionUuid: 'session-bootstrap-111',
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({ sameSite: 'none' }),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js?puter.auth.token=bootstrap-token&puter.app_instance_id=instance-111&foo=bar',
cookies: {},
headers: {},
query: {
'puter.auth.token': 'bootstrap-token',
'puter.app_instance_id': 'instance-111',
foo: 'bar',
},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
expect(authService.createPrivateAssetToken).toHaveBeenCalledWith({
appUid: 'app-11111111-1111-1111-1111-111111111111',
userUid: 'user-bootstrap-111',
sessionUuid: 'session-bootstrap-111',
subdomain: 'paid',
privateHost: 'paid.puter.dev',
});
expect(mockRes.cookie).toHaveBeenCalledWith(
'puter.private.asset.token',
'private-token',
{ sameSite: 'none' },
);
expect(mockRes.redirect).not.toHaveBeenCalled();
expect(filesystemNodeCallCount).toBeGreaterThanOrEqual(2);
expect(mockNext).not.toHaveBeenCalled();
});
it('accepts nested query token key for bootstrap auth', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
event.result.redirectUrl = 'https://apps.puter.com/app/paid-app';
});
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockImplementation(() => {
throw new Error('token_auth_failed');
}),
resolvePrivateBootstrapIdentityFromToken: vi.fn().mockResolvedValue({
userUid: 'user-111',
sessionUuid: 'session-111',
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockResolvedValue({
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/index.html',
originalUrl: '/index.html?puter.auth.token=bootstrap-token',
cookies: {},
headers: {},
query: {
puter: {
auth: {
token: 'bootstrap-token',
},
},
},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
expect(authService.resolvePrivateBootstrapIdentityFromToken)
.toHaveBeenCalledWith('bootstrap-token', {
expectedAppUids: ['app-11111111-1111-1111-1111-111111111111'],
});
expect(mockRes.redirect).toHaveBeenCalledWith('https://apps.puter.com/app/paid-app');
expect(mockRes.send).not.toHaveBeenCalled();
expect(mockRes.cookie).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});
it('skips private app gate when feature flag is disabled', async () => {
config.enable_private_app_access_gate = false;
const eventEmit = vi.fn();
const rootDirectoryNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
};
const missingFileNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(false),
get: vi.fn().mockResolvedValue(null),
};
let filesystemNodeCallCount = 0;
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.site.puter.localhost',
subdomains: ['paid'],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js',
query: {},
cookies: {},
headers: {},
on: vi.fn(),
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(mockRes.redirect).not.toHaveBeenCalled();
expect(eventEmit).not.toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockNext).not.toHaveBeenCalled();
});
});
describe('public hosted actor bootstrap', () => {
let capturedMiddleware;
const createRootAndMissingNodes = () => {
const rootDirectoryNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
};
const missingFileNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(false),
get: vi.fn().mockResolvedValue(null),
};
return { rootDirectoryNode, missingFileNode };
};
beforeEach(() => {
vi.clearAllMocks();
config.enable_private_app_access_gate = true;
Context.get = vi.fn().mockImplementation((key) => {
if ( key === 'actor' ) return undefined;
return mockContextInstance;
});
Context.set = vi.fn();
getUserMockImpl = async () => null;
getAppMockImpl = async () => null;
capturedMiddleware = puterSiteMiddleware;
});
it('mints public hosted actor cookie from session identity on non-private app', async () => {
const { rootDirectoryNode, missingFileNode } = createRootAndMissingNodes();
let filesystemNodeCallCount = 0;
const authService = {
getPublicHostedActorCookieName: vi.fn().mockReturnValue('puter.public.hosted.actor.token'),
verifyPublicHostedActorToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockResolvedValue({
type: {},
get_related_actor: vi.fn().mockReturnValue({
type: {
user: { uuid: 'user-public-111' },
session: 'session-public-111',
},
}),
}),
createPublicHostedActorToken: vi.fn().mockReturnValue('public-hosted-token'),
getPublicHostedActorCookieOptions: vi.fn().mockReturnValue({ sameSite: 'none' }),
app_uid_from_origin: vi.fn().mockResolvedValue('app-origin-fallback-111'),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-public-11111111-1111-1111-1111-111111111111',
name: 'public-app',
is_private: 0,
index_url: 'https://paid.site.puter.localhost/',
});
const mockReq = {
hostname: 'paid.site.puter.localhost',
subdomains: ['paid'],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js',
query: {},
cookies: {
'puter.session.token': 'session-token',
},
headers: {},
on: vi.fn(),
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.verifyPublicHostedActorToken).not.toHaveBeenCalled();
expect(authService.authenticate_from_token).toHaveBeenCalledWith('session-token');
expect(authService.createPublicHostedActorToken).toHaveBeenCalledWith({
appUid: 'app-public-11111111-1111-1111-1111-111111111111',
userUid: 'user-public-111',
sessionUuid: 'session-public-111',
subdomain: 'paid',
host: 'paid.site.puter.localhost',
});
expect(authService.app_uid_from_origin).not.toHaveBeenCalled();
expect(authService.getPublicHostedActorCookieOptions).toHaveBeenCalledWith({
requestHostname: 'paid.site.puter.localhost',
});
expect(mockRes.cookie).toHaveBeenCalledWith(
'puter.public.hosted.actor.token',
'public-hosted-token',
{ sameSite: 'none' },
);
expect(Context.set).toHaveBeenCalledWith('actor', expect.any(Object));
expect(mockRes.redirect).not.toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockNext).not.toHaveBeenCalled();
});
it('uses valid public hosted actor cookie without re-authenticating', async () => {
const { rootDirectoryNode, missingFileNode } = createRootAndMissingNodes();
let filesystemNodeCallCount = 0;
const authService = {
getPublicHostedActorCookieName: vi.fn().mockReturnValue('puter.public.hosted.actor.token'),
verifyPublicHostedActorToken: vi.fn().mockReturnValue({
appUid: 'app-public-22222222-2222-2222-2222-222222222222',
userUid: 'user-public-222',
sessionUuid: 'session-public-222',
subdomain: 'paid',
host: 'paid.site.puter.localhost',
}),
authenticate_from_token: vi.fn(),
createPublicHostedActorToken: vi.fn(),
getPublicHostedActorCookieOptions: vi.fn(),
app_uid_from_origin: vi.fn(),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-public-22222222-2222-2222-2222-222222222222',
name: 'public-app',
is_private: 0,
index_url: 'https://paid.site.puter.localhost/',
});
const mockReq = {
hostname: 'paid.site.puter.localhost',
subdomains: ['paid'],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js',
query: {},
cookies: {
'puter.public.hosted.actor.token': 'public-cookie-token',
},
headers: {},
on: vi.fn(),
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.verifyPublicHostedActorToken).toHaveBeenCalledWith(
'public-cookie-token',
{
expectedAppUid: 'app-public-22222222-2222-2222-2222-222222222222',
expectedSubdomain: 'paid',
expectedHost: 'paid.site.puter.localhost',
},
);
expect(authService.authenticate_from_token).not.toHaveBeenCalled();
expect(authService.createPublicHostedActorToken).not.toHaveBeenCalled();
expect(authService.app_uid_from_origin).not.toHaveBeenCalled();
expect(mockRes.cookie).not.toHaveBeenCalled();
expect(Context.set).toHaveBeenCalledWith('actor', expect.any(Object));
const [, actor] = Context.set.mock.calls[0];
expect(actor?.type?.user?.uuid).toBe('user-public-222');
expect(mockRes.redirect).not.toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockNext).not.toHaveBeenCalled();
});
it('sets public hosted cookie and redirects to sanitized url for bootstrap tokens', async () => {
const { rootDirectoryNode, missingFileNode } = createRootAndMissingNodes();
let filesystemNodeCallCount = 0;
const authService = {
getPublicHostedActorCookieName: vi.fn().mockReturnValue('puter.public.hosted.actor.token'),
verifyPublicHostedActorToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockResolvedValue({
type: {},
get_related_actor: vi.fn().mockReturnValue({
type: {
user: { uuid: 'user-public-333' },
session: 'session-public-333',
},
}),
}),
createPublicHostedActorToken: vi.fn().mockReturnValue('public-hosted-token-333'),
getPublicHostedActorCookieOptions: vi.fn().mockReturnValue({ sameSite: 'none' }),
app_uid_from_origin: vi.fn(),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-public-33333333-3333-3333-3333-333333333333',
name: 'public-app',
is_private: 0,
index_url: 'https://paid.site.puter.localhost/',
});
const mockReq = {
hostname: 'paid.site.puter.localhost',
subdomains: ['paid'],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js?puter.auth.token=bootstrap-token&foo=bar',
query: {
'puter.auth.token': 'bootstrap-token',
foo: 'bar',
},
cookies: {},
headers: {},
on: vi.fn(),
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
expect(authService.createPublicHostedActorToken).toHaveBeenCalledWith({
appUid: 'app-public-33333333-3333-3333-3333-333333333333',
userUid: 'user-public-333',
sessionUuid: 'session-public-333',
subdomain: 'paid',
host: 'paid.site.puter.localhost',
});
expect(mockRes.cookie).toHaveBeenCalledWith(
'puter.public.hosted.actor.token',
'public-hosted-token-333',
{ sameSite: 'none' },
);
expect(mockRes.redirect).toHaveBeenCalledWith('/asset.js?foo=bar');
expect(mockNext).not.toHaveBeenCalled();
});
it('uses strict bootstrap identity verification when available', async () => {
const { rootDirectoryNode, missingFileNode } = createRootAndMissingNodes();
let filesystemNodeCallCount = 0;
const authService = {
getPublicHostedActorCookieName: vi.fn().mockReturnValue('puter.public.hosted.actor.token'),
verifyPublicHostedActorToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
resolvePrivateBootstrapIdentityFromToken: vi.fn().mockResolvedValue({
userUid: 'user-public-555',
sessionUuid: 'session-public-555',
}),
authenticate_from_token: vi.fn(),
createPublicHostedActorToken: vi.fn().mockReturnValue('public-hosted-token-555'),
getPublicHostedActorCookieOptions: vi.fn().mockReturnValue({ sameSite: 'none' }),
app_uid_from_origin: vi.fn(),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-public-55555555-5555-5555-5555-555555555555',
name: 'public-app',
is_private: 0,
index_url: 'https://paid.site.puter.localhost/',
});
const mockReq = {
hostname: 'paid.site.puter.localhost',
subdomains: ['paid'],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js?puter.auth.token=bootstrap-token&foo=bar',
query: {
'puter.auth.token': 'bootstrap-token',
foo: 'bar',
},
cookies: {},
headers: {},
on: vi.fn(),
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.resolvePrivateBootstrapIdentityFromToken).toHaveBeenCalledWith(
'bootstrap-token',
{
expectedAppUid: 'app-public-55555555-5555-5555-5555-555555555555',
},
);
expect(authService.authenticate_from_token).not.toHaveBeenCalled();
expect(authService.createPublicHostedActorToken).toHaveBeenCalledWith({
appUid: 'app-public-55555555-5555-5555-5555-555555555555',
userUid: 'user-public-555',
sessionUuid: 'session-public-555',
subdomain: 'paid',
host: 'paid.site.puter.localhost',
});
expect(mockRes.redirect).toHaveBeenCalledWith('/asset.js?foo=bar');
expect(mockNext).not.toHaveBeenCalled();
});
it('short-circuits without auth calls when no identity tokens exist', async () => {
const { rootDirectoryNode, missingFileNode } = createRootAndMissingNodes();
let filesystemNodeCallCount = 0;
const authService = {
getPublicHostedActorCookieName: vi.fn().mockReturnValue('puter.public.hosted.actor.token'),
verifyPublicHostedActorToken: vi.fn(),
authenticate_from_token: vi.fn(),
createPublicHostedActorToken: vi.fn(),
getPublicHostedActorCookieOptions: vi.fn(),
app_uid_from_origin: vi.fn(),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-public-44444444-4444-4444-4444-444444444444',
name: 'public-app',
is_private: 0,
index_url: 'https://paid.site.puter.localhost/',
});
const mockReq = {
hostname: 'paid.site.puter.localhost',
subdomains: ['paid'],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js',
query: {},
cookies: {},
headers: {},
on: vi.fn(),
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.verifyPublicHostedActorToken).not.toHaveBeenCalled();
expect(authService.authenticate_from_token).not.toHaveBeenCalled();
expect(authService.createPublicHostedActorToken).not.toHaveBeenCalled();
expect(authService.app_uid_from_origin).not.toHaveBeenCalled();
expect(mockRes.cookie).not.toHaveBeenCalled();
expect(mockRes.redirect).not.toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockNext).not.toHaveBeenCalled();
});
});
});
================================================
FILE: src/backend/src/routers/itemMetadata.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const { validate_signature_auth, get_url_from_req, is_valid_uuid4, get_dir_size, id2path } = require('../helpers');
const { DB_READ } = require('../services/database/consts');
// -----------------------------------------------------------------------//
// GET /itemMetadata
// -----------------------------------------------------------------------//
router.get('/itemMetadata', async (req, res, next) => {
// Check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// Validate URL signature
try {
validate_signature_auth(get_url_from_req(req), 'read');
}
catch (e) {
console.log(e);
return res.status(403).send(e);
}
// Validation
if ( ! req.query.uid )
{
return res.status(400).send('`uid` is required');
}
// uid must be a string
else if ( req.query.uid && typeof req.query.uid !== 'string' )
{
return res.status(400).send('uid must be a string.');
}
// uid cannot be empty
else if ( req.query.uid && req.query.uid.trim() === '' )
{
return res.status(400).send('uid cannot be empty');
}
// uid must be a valid uuid
else if ( ! is_valid_uuid4(req.query.uid) )
{
return res.status(400).send('uid must be a valid uuid');
}
// modules
const { uuid2fsentry } = require('../helpers');
const uid = req.query.uid;
const item = await uuid2fsentry(uid);
// check if item owner is suspended
const user = await require('../helpers').get_user({ id: item.user_id });
if ( ! user ) {
return res.status(400).send('User not found');
}
if ( user.suspended )
{
return res.status(401).send({ error: 'Account suspended' });
}
if ( ! item )
{
return res.status(400).send('Item not found');
}
const mime = require('mime-types');
const contentType = mime.contentType(res.name);
const itemMetadata = {
uid: item.uuid,
name: item.name,
is_dir: item.is_dir,
type: contentType,
size: item.is_dir ? await get_dir_size(await id2path(item.id), user) : item.size,
created: item.created,
modified: item.modified,
};
// ---------------------------------------------------------------//
// return_path
// ---------------------------------------------------------------//
if ( req.query.return_path === 'true' || req.query.return_path === '1' ) {
const { id2path } = require('../helpers');
itemMetadata.path = await id2path(item.id);
}
// ---------------------------------------------------------------//
// Versions
// ---------------------------------------------------------------//
if ( req.query.return_versions ) {
const db = req.services.get('database').get(DB_READ, 'itemMetadata.js');
itemMetadata.versions = [];
let versions = await db.read('SELECT * FROM fsentry_versions WHERE fsentry_id = ?',
[item.id]);
if ( versions.length > 0 ) {
for ( let index = 0; index < versions.length; index++ ) {
const version = versions[index];
itemMetadata.versions.push({
id: version.version_id,
message: version.message,
timestamp: version.ts_epoch,
});
}
}
}
return res.send(itemMetadata);
});
module.exports = router;
================================================
FILE: src/backend/src/routers/kvstore/clearItems.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
module.exports = eggspress('/clearItems', {
subdomain: 'api',
auth: true,
verified: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
// TODO: model these parameters; validation is contained in brackets
// so that it can be easily move.
let { app } = req.body;
// Validation for `app`
if ( ! app ) {
throw APIError.create('field_missing', null, { key: 'app' });
}
const svc_mysql = req.services.get('mysql');
// TODO: Check if used anywhere, maybe remove
// eslint-disable-next-line no-undef
const dbrw = svc_mysql.get(DB_MODE_WRITE, 'kvstore-clearItems');
await dbrw.execute('DELETE FROM kv WHERE user_id=? AND app=?',
[
req.user.id,
app,
]);
return res.send({});
});
================================================
FILE: src/backend/src/routers/kvstore/getItem.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const auth = require('../../middleware/auth.js');
const config = require('../../config.js');
const { Context } = require('../../util/context.js');
const { Actor, AppUnderUserActorType, UserActorType } = require('../../services/auth/Actor.js');
const { DB_READ } = require('../../services/database/consts.js');
// -----------------------------------------------------------------------//
// POST /getItem
// -----------------------------------------------------------------------//
router.post('/getItem', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../../helpers.js').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// validation
if ( ! req.body.key )
{
return res.status(400).send('`key` is required.');
}
// check size of key, if it's too big then it's an invalid key and we don't want to waste time on it
else if ( Buffer.byteLength(req.body.key, 'utf8') > config.kv_max_key_size )
{
return res.status(400).send('`key` is too long.');
}
const actor = req.body.app
? await Actor.create(AppUnderUserActorType, {
user: req.user,
app_uid: req.body.app,
})
: await Actor.create(UserActorType, {
user: req.user,
})
;
Context.set('actor', actor);
// Try KV 1 first
const svc_driver = Context.get('services').get('driver');
let driver_result;
try {
const driver_response = await svc_driver.call({
iface: 'puter-kvstore',
method: 'get',
args: { key: req.body.key },
});
if ( ! driver_response.success ) {
throw new Error(driver_response.error?.message ?? 'Unknown error');
}
driver_result = driver_response.result;
} catch ( e ) {
return res.status(400).send(`puter-kvstore driver error: ${ e.message}`);
}
if ( driver_result ) {
return res.send({ key: req.body.key, value: driver_result });
}
// modules
const db = req.services.get('database').get(DB_READ, 'getItem-fallback');
// get murmurhash module
const murmurhash = require('murmurhash');
// hash key for faster search in DB
const key_hash = murmurhash.v3(req.body.key);
let kv;
// Get value from DB
// If app is specified, then get value for that app
if ( req.body.app ) {
kv = await db.read('SELECT * FROM kv WHERE user_id=? AND app=? AND kkey_hash=? LIMIT 1',
[
req.user.id,
req.body.app,
key_hash,
]);
// If app is not specified, then get value for global (i.e. system) variables which is app='global'
} else {
kv = await db.read('SELECT * FROM kv WHERE user_id=? AND (app IS NULL OR app = \'global\') AND kkey_hash=? LIMIT 1',
[
req.user.id,
key_hash,
]);
}
// send results to client
if ( kv[0] )
{
return res.send({
key: kv[0].kkey,
value: kv[0].value,
});
}
else
{
return res.send(null);
}
});
module.exports = router;
================================================
FILE: src/backend/src/routers/kvstore/listItems.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { DB_READ } = require('../../services/database/consts');
module.exports = eggspress('/listItems', {
subdomain: 'api',
auth: true,
verified: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
let { app } = req.body;
// Validation for `app`
if ( ! app ) {
throw APIError.create('field_missing', null, { key: 'app' });
}
const db = req.services.get('database').get(DB_READ, 'kv');
let rows = await db.read('SELECT kkey, value FROM kv WHERE user_id=? AND app=?',
[
req.user.id,
app,
]);
rows = rows.map(row => ({
key: row.kkey,
value: row.value,
}));
return res.send(rows);
});
================================================
FILE: src/backend/src/routers/kvstore/setItem.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const auth = require('../../middleware/auth.js');
const config = require('../../config.js');
const { app_exists, byte_format } = require('../../helpers.js');
const { Actor, AppUnderUserActorType, UserActorType } = require('../../services/auth/Actor.js');
const { Context } = require('../../util/context.js');
// -----------------------------------------------------------------------//
// POST /setItem
// -----------------------------------------------------------------------//
router.post('/setItem', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../../helpers.js').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// validation
if ( ! req.body.key )
{
return res.status(400).send('`key` is required');
}
else if ( typeof req.body.key !== 'string' )
{
return res.status(400).send('`key` must be a string');
}
else if ( ! req.body.value )
{
return res.status(400).send('`value` is required');
}
req.body.key = String(req.body.key);
req.body.value = String(req.body.value);
if ( Buffer.byteLength(req.body.key, 'utf8') > config.kv_max_key_size )
{
return res.status(400).send(`\`key\` is too large. Max size is ${byte_format(config.kv_max_key_size)}.`);
}
else if ( Buffer.byteLength(req.body.value, 'utf8') > config.kv_max_value_size )
{
return res.status(400).send(`\`value\` is too large. Max size is ${byte_format(config.kv_max_value_size)}.`);
}
else if ( req.body.app && !await app_exists({ uid: req.body.app }) )
{
return res.status(400).send('`app` does not exist');
}
// insert into KV 1
const actor = req.body.app
? await Actor.create(AppUnderUserActorType, {
user: req.user,
app_uid: req.body.app,
})
: await Actor.create(UserActorType, {
user: req.user,
})
;
Context.set('actor', actor);
const svc_driver = Context.get('services').get('driver');
let driver_result;
try {
const driver_response = await svc_driver.call({
iface: 'puter-kvstore',
method: 'set',
args: {
key: req.body.key,
value: req.body.value,
},
});
if ( ! driver_response.success ) {
throw new Error(driver_response.error?.message ?? 'Unknown error');
}
driver_result = driver_response.result;
} catch (e) {
return res.status(400).send(`puter-kvstore driver error: ${ e.message}`);
}
// send results to client
return res.send({});
});
module.exports = router;
================================================
FILE: src/backend/src/routers/login.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
const { get_user, body_parser_error_handler, invalidate_cached_user } = require('../helpers');
const config = require('../config');
const { DB_WRITE } = require('../services/database/consts');
const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middleware');
const complete_ = async ({ req, res, user }) => {
const svc_auth = req.services.get('auth');
const { session, token: session_token } = await svc_auth.create_session_token(user, { req });
const gui_token = svc_auth.create_gui_token(user, session);
// HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie)
res.cookie(config.cookie_name, session_token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
// response body: GUI token only (client never gets session token)
return res.send({
proceed: true,
next_step: 'complete',
token: gui_token,
user: {
username: user.username,
uuid: user.uuid,
email: user.email,
email_confirmed: user.email_confirmed,
is_temp: (user.password === null && user.email === null),
},
});
};
// -----------------------------------------------------------------------//
// POST /login
// -----------------------------------------------------------------------//
router.post('/login', express.json(), body_parser_error_handler, (req, res, next) => {
// Add diagnostic middleware to log captcha data
if ( process.env.DEBUG ) {
console.log('====== LOGIN CAPTCHA DIAGNOSTIC ======');
console.log('LOGIN REQUEST RECEIVED with captcha data:', {
hasCaptchaToken: !!req.body.captchaToken,
hasCaptchaAnswer: !!req.body.captchaAnswer,
captchaToken: req.body.captchaToken ? `${req.body.captchaToken.substring(0, 8) }...` : undefined,
captchaAnswer: req.body.captchaAnswer,
});
}
next();
}, requireCaptcha({ strictMode: true, eventType: 'login' }), async (req, res, next) => {
// either api. subdomain or no subdomain
if ( require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '' ) {
next();
}
// modules
const bcrypt = require('bcrypt');
const validator = require('validator');
// either username or email must be provided
if ( !req.body.username && !req.body.email ) {
return res.status(400).send('Username or email is required.');
}
// password is required
else if ( ! req.body.password )
{
return res.status(400).send('Password is required.');
}
// password must be a string
else if ( typeof req.body.password !== 'string' && !(req.body.password instanceof String) )
{
return res.status(400).send('Password must be a string.');
}
// if password is too short it's invalid, no need to do a db lookup
else if ( req.body.password.length < config.min_pass_length )
{
return res.status(400).send('Invalid password.');
}
// username, if present, must be a string
else if ( req.body.username && typeof req.body.username !== 'string' && !(req.body.username instanceof String) )
{
return res.status(400).send('username must be a string.');
}
// if username doesn't pass regex test it's invalid anyway, no need to do DB lookup
else if ( req.body.username && !req.body.username.match(config.username_regex) )
{
return res.status(400).send('Invalid username.');
}
// email, if present, must be a string
else if ( req.body.email && typeof req.body.email !== 'string' && !(req.body.email instanceof String) )
{
return res.status(400).send('email must be a string.');
}
// if email is invalid, no need to do DB lookup anyway
else if ( req.body.email && !validator.isEmail(req.body.email) )
{
return res.status(400).send('Invalid email.');
}
/** @type {import('../services/abuse-prevention/EdgeRateLimitService').EdgeRateLimitService} */
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('login', true) ) {
return res.status(429).send('Too many requests.');
}
try {
let user;
// log in using username
if ( req.body.username ) {
user = await get_user({ username: req.body.username, cached: false });
if ( ! user ) {
svc_edgeRateLimit.incr('login');
return res.status(400).send('Username not found.');
}
}
// log in using email
else if ( validator.isEmail(req.body.email) ) {
user = await get_user({ email: req.body.email, cached: false });
if ( ! user ) {
svc_edgeRateLimit.incr('login');
return res.status(400).send('Email not found.');
}
}
if ( user.username === 'system' && config.allow_system_login !== true ) {
svc_edgeRateLimit.incr('login');
return res.status(400).send(
req.body.username
? 'Username not found.'
: 'Email not found.',
);
}
// is user suspended?
if ( user.suspended ) {
svc_edgeRateLimit.incr('login');
return res.status(401).send('This account is suspended.');
}
// pseudo user?
// todo make this better, maybe ask them to create an account or send them an activation link
if ( user.password === null ) {
svc_edgeRateLimit.incr('login');
return res.status(400).send('Incorrect password.');
}
// check password
if ( await bcrypt.compare(req.body.password, user.password) ) {
// We create a JWT that can ONLY be used on the endpoint that
// accepts the OTP code.
if ( user.otp_enabled ) {
const svc_token = req.services.get('token');
const otp_jwt_token = svc_token.sign('otp', {
user_uid: user.uuid,
}, { expiresIn: '5m' });
return res.status(202).send({
proceed: true,
next_step: 'otp',
otp_jwt_token: otp_jwt_token,
});
}
return await complete_({ req, res, user });
} else {
svc_edgeRateLimit.incr('login');
return res.status(400).send('Incorrect password.');
}
} catch (e) {
console.error(e);
svc_edgeRateLimit.incr('login');
return res.status(400).send(e);
}
});
router.post('/login/otp', express.json(), body_parser_error_handler, requireCaptcha({ strictMode: true, eventType: 'login_otp' }), async (req, res, next) => {
// either api. subdomain or no subdomain
if ( require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '' )
{
next();
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('login-otp') ) {
return res.status(429).send('Too many requests.');
}
if ( ! req.body.token ) {
return res.status(400).send('token is required.');
}
if ( ! req.body.code ) {
return res.status(400).send('code is required.');
}
const svc_token = req.services.get('token');
let decoded; try {
decoded = svc_token.verify('otp', req.body.token);
} catch ( e ) {
return res.status(400).send('Invalid token.');
}
if ( ! decoded.user_uid ) {
return res.status(400).send('Invalid token.');
}
const user = await get_user({ uuid: decoded.user_uid, cached: false });
if ( ! user ) {
return res.status(400).send('User not found.');
}
const svc_otp = req.services.get('otp');
if ( ! svc_otp.verify(user.username, user.otp_secret, req.body.code) ) {
// THIS MAY BE COUNTER-INTUITIVE
//
// A successfully handled request, with the correct format,
// but incorrect credentials when NOT using the HTTP
// authentication framework provided by RFC 7235, SHOULD
// return status 200.
//
// Source: I asked Julian Reschke in an email, and then he
// contributed to this discussion:
// https://stackoverflow.com/questions/32752578
return res.status(200).send({
proceed: false,
});
}
return await complete_({ req, res, user });
});
router.post('/login/recovery-code', express.json(), body_parser_error_handler, requireCaptcha({ strictMode: true, eventType: 'login_recovery' }), async (req, res, next) => {
// either api. subdomain or no subdomain
if ( require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '' )
{
next();
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('login-recovery') ) {
return res.status(429).send('Too many requests.');
}
if ( ! req.body.token ) {
return res.status(400).send('token is required.');
}
if ( ! req.body.code ) {
return res.status(400).send('code is required.');
}
const svc_token = req.services.get('token');
let decoded; try {
decoded = svc_token.verify('otp', req.body.token);
} catch ( e ) {
return res.status(400).send('Invalid token.');
}
if ( ! decoded.user_uid ) {
return res.status(400).send('Invalid token.');
}
const user = await get_user({ uuid: decoded.user_uid, cached: false });
if ( ! user ) {
return res.status(400).send('User not found.');
}
const code = req.body.code;
const crypto = require('crypto');
const codes = user.otp_recovery_codes.split(',');
const hashed_code = crypto
.createHash('sha256')
.update(code)
.digest('base64')
// We're truncating the hash for easier storage, so we have 128
// bits of entropy instead of 256. This is plenty for recovery
// codes, which have only 48 bits of entropy to begin with.
.slice(0, 22);
if ( ! codes.includes(hashed_code) ) {
return res.status(200).send({
proceed: false,
});
}
// Remove the code from the list
const index = codes.indexOf(hashed_code);
codes.splice(index, 1);
// update user
const db = req.services.get('database').get(DB_WRITE, '2fa');
await db.write(
'UPDATE user SET otp_recovery_codes = ? WHERE uuid = ?',
[codes.join(','), user.uuid],
);
user.otp_recovery_codes = codes.join(',');
invalidate_cached_user(user);
return await complete_({ req, res, user });
});
module.exports = router;
================================================
FILE: src/backend/src/routers/logout.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
const auth = require('../middleware/auth.js');
const config = require('../config');
// -----------------------------------------------------------------------//
// POST /logout
// -----------------------------------------------------------------------//
router.post('/logout', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '' )
{
next();
}
// check anti-csrf token
const svc_antiCSRF = req.services.get('anti-csrf');
if ( ! svc_antiCSRF.consume_token(req.user.uuid, req.body.anti_csrf) ) {
return res.status(400).json({ message: 'incorrect anti-CSRF token' });
}
// delete cookie
res.clearCookie(config.cookie_name);
// delete session
(async () => {
if ( ! req.token ) return;
try {
const svc_auth = req.services.get('auth');
await svc_auth.remove_session_by_token(req.token);
} catch (e) {
console.log(e);
}
})();
//---------------------------------------------------------
// DANGER ZONE: delete temp user and all its data
//---------------------------------------------------------
if ( req.user.password === null && req.user.email === null ) {
const { deleteUser } = require('../helpers');
deleteUser(req.user.id);
}
// send response
res.send('logged out');
});
module.exports = router;
================================================
FILE: src/backend/src/routers/open_item.js
================================================
/*
* 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 .
*/
'use strict';
const eggspress = require('../api/eggspress.js');
const FSNodeParam = require('../api/filesystem/FSNodeParam.js');
const { Context } = require('../util/context.js');
const { UserActorType } = require('../services/auth/Actor.js');
const APIError = require('../api/APIError.js');
const { sign_file, suggestedAppForFsEntry, get_app } = require('../helpers.js');
// -----------------------------------------------------------------------//
// POST /open_item
// -----------------------------------------------------------------------//
module.exports = eggspress('/open_item', {
subdomain: 'api',
auth2: true,
verified: true,
json: true,
allowedMethods: ['POST'],
alias: { uid: 'path' },
parameters: {
subject: new FSNodeParam('path'),
},
}, async (req, res) => {
const subject = req.values.subject;
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
if ( ! await subject.exists() ) {
throw APIError.create('subject_does_not_exist');
}
const svc_acl = Context.get('services').get('acl');
if ( ! await svc_acl.check(actor, subject, 'read') ) {
throw await svc_acl.get_safe_acl_error(actor, subject, 'read');
}
let action = 'write';
if ( ! await svc_acl.check(actor, subject, 'write') ) {
action = 'read';
}
const signature = await sign_file(subject.entry, action);
const suggested_apps = await suggestedAppForFsEntry(subject.entry);
const apps_only_one = suggested_apps.slice(0, 1);
const _app = apps_only_one[0];
if ( ! _app ) {
throw APIError.create('no_suitable_app', null, { entry_name: subject.entry.name });
}
const app = await get_app(Object.prototype.hasOwnProperty.call(_app, 'id')
? { id: _app.id }
: { uid: _app.uid }) ?? apps_only_one[0];
if ( ! app ) {
throw APIError.create('no_suitable_app', null, { entry_name: subject.entry.name });
}
// Grant permission to open the file
// Note: We always grant write permission here. If the user only
// has read permission this is still safe; user permissions
// are always checked during an app access.
const perm = action === 'write' ? 'write' : 'read';
const permission = `fs:${subject.uid}:${perm}`;
const svc_permission = Context.get('services').get('permission');
await svc_permission.grant_user_app_permission(actor, app.uid, permission, {}, { reason: 'open_item' });
// Generate user-app token
const svc_auth = Context.get('services').get('auth');
const token = await svc_auth.get_user_app_token(app.uid);
// TODO: DRY
// remove some privileged information
delete app.id;
delete app.approved_for_listing;
delete app.approved_for_opening_items;
delete app.godmode;
delete app.owner_user_id;
return res.send({
signature: signature,
token,
suggested_apps: [app],
});
});
================================================
FILE: src/backend/src/routers/passwd.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const { invalidate_cached_user, get_user } = require('../helpers');
const router = new express.Router();
const auth = require('../middleware/auth.js');
const { DB_WRITE } = require('../services/database/consts');
// -----------------------------------------------------------------------//
// POST /passwd
// -----------------------------------------------------------------------//
router.post('/passwd', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
const db = req.services.get('database').get(DB_WRITE, 'auth');
const bcrypt = require('bcrypt');
if ( ! req.body.old_pass )
{
return res.status(401).send('old_pass is required');
}
// old_pass must be a string
else if ( typeof req.body.old_pass !== 'string' )
{
return res.status(400).send('old_pass must be a string.');
}
else if ( ! req.body.new_pass )
{
return res.status(401).send('new_pass is required');
}
// new_pass must be a string
else if ( typeof req.body.new_pass !== 'string' )
{
return res.status(400).send('new_pass must be a string.');
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('passwd') ) {
return res.status(429).send('Too many requests.');
}
try {
const user = await get_user({ id: req.user.id, force: true });
// check old_pass
const isMatch = await bcrypt.compare(req.body.old_pass, user.password);
if ( ! isMatch )
{
return res.status(400).send('old_pass does not match your current password.');
}
// check new_pass length
// todo use config, 6 is hard-coded and wrong
else if ( req.body.new_pass.length < 6 )
{
return res.status(400).send('new_pass must be at least 6 characters long.');
}
else {
await db.write(
'UPDATE user SET password=?, `pass_recovery_token` = NULL, `change_email_confirm_token` = NULL WHERE `id` = ?',
[await bcrypt.hash(req.body.new_pass, 8), req.user.id],
);
invalidate_cached_user(req.user);
const svc_email = req.services.get('email');
svc_email.send_email({ email: user.email }, 'password_change_notification');
return res.send('Password successfully updated.');
}
} catch (e) {
return res.status(401).send('an error occured');
}
});
module.exports = router;
================================================
FILE: src/backend/src/routers/puterai/openai/chat_completions.js
================================================
/*
* 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 .
*/
'use strict';
const crypto = require('node:crypto');
const APIError = require('../../../api/APIError.js');
const eggspress = require('../../../api/eggspress.js');
const { TypedValue } = require('../../../services/drivers/meta/Runtime.js');
const { Context } = require('../../../util/context.js');
const DEFAULT_PROVIDER = 'openai-completion';
const extractTextContent = (content) => {
if ( content === undefined || content === null ) return '';
if ( typeof content === 'string' ) return content;
if ( Array.isArray(content) ) {
return content.map((part) => {
if ( typeof part === 'string' ) return part;
if ( part && typeof part.text === 'string' ) return part.text;
if ( part && typeof part.content === 'string' ) return part.content;
return '';
}).join('');
}
if ( typeof content === 'object' ) {
if ( typeof content.text === 'string' ) return content.text;
if ( typeof content.content === 'string' ) return content.content;
}
return '';
};
const normalizeToolCallsFromContent = (content) => {
if ( ! Array.isArray(content) ) return undefined;
const toolCalls = [];
for ( const part of content ) {
if ( !part || typeof part !== 'object' ) continue;
if ( part.type !== 'tool_use' ) continue;
toolCalls.push({
id: part.id,
type: 'function',
function: {
name: part.name,
arguments: typeof part.input === 'string' ? part.input : JSON.stringify(part.input ?? {}),
},
});
}
return toolCalls.length ? toolCalls : undefined;
};
const buildUsage = (usage) => {
const promptTokens = usage?.prompt_tokens ?? usage?.input_tokens ?? 0;
const completionTokens = usage?.completion_tokens ?? usage?.output_tokens ?? 0;
return {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
};
};
const svc_web = Context.get('services').get('web-server');
svc_web.allow_undefined_origin(/^\/puterai\/openai\/v1\/chat\/completions(\/.*)?$/);
module.exports = eggspress('/openai/v1/chat/completions', {
auth2: true,
json: true,
jsonCanBeLarge: true,
allowedMethods: ['POST'],
}, async (req, res) => {
// We don't allow apps
if ( Context.get('actor').type.app ) {
throw APIError.create('permission_denied');
}
const body = req.body || {};
const stream = !!body.stream;
if ( ! Array.isArray(body.messages) ) {
throw APIError.create('field_invalid', {
key: 'messages',
expected: 'an array of chat messages',
got: typeof body.messages,
});
}
const ctx = Context.get();
const services = ctx.get('services');
const svcAiChat = services.get('ai-chat');
let model = body.model;
if ( ! model ) {
const providerName = body.provider || DEFAULT_PROVIDER;
const provider = svcAiChat.getProvider(providerName);
if ( ! provider ) {
throw APIError.create('field_missing', { key: 'model' });
}
model = provider.getDefaultModel();
}
const completeArgs = {
messages: body.messages,
model,
stream,
...(body.tools ? { tools: body.tools } : {}),
...(body.temperature !== undefined ? { temperature: body.temperature } : {}),
...(body.max_tokens !== undefined ? { max_tokens: body.max_tokens } : {}),
...(body.provider ? { provider: body.provider } : {}),
};
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, '')}`;
const created = Math.floor(Date.now() / 1000);
const result = await svcAiChat.complete(completeArgs);
if ( stream ) {
if ( ! (result instanceof TypedValue) ) {
throw APIError.create('internal_error', { message: 'expected streaming response' });
}
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
let buffer = '';
let usage = null;
let toolCallIndex = 0;
let sawToolCalls = false;
const sendChunk = (delta, finishReason = null, extra = {}) => {
const payload = {
id: completionId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta,
logprobs: null,
finish_reason: finishReason,
},
],
...extra,
};
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
const streamValue = result.value;
streamValue.on('data', (chunk) => {
buffer += chunk.toString('utf8');
let newlineIndex;
while ( (newlineIndex = buffer.indexOf('\n')) >= 0 ) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if ( ! line ) continue;
let event;
try {
event = JSON.parse(line);
} catch {
continue;
}
if ( event.type === 'text' && typeof event.text === 'string' ) {
sendChunk({ content: event.text });
}
if ( event.type === 'tool_use' ) {
sawToolCalls = true;
sendChunk({
tool_calls: [
{
index: toolCallIndex++,
id: event.id,
type: 'function',
function: {
name: event.name,
arguments: typeof event.input === 'string' ? event.input : JSON.stringify(event.input ?? {}),
},
},
],
});
}
if ( event.type === 'usage' ) {
usage = event.usage;
}
}
});
streamValue.on('end', () => {
const finishReason = sawToolCalls ? 'tool_calls' : 'stop';
sendChunk({}, finishReason, usage ? { usage: buildUsage(usage) } : {});
res.write('data: [DONE]\n\n');
res.end();
});
streamValue.on('error', (err) => {
res.write(`data: ${JSON.stringify({
error: {
message: err?.message || 'stream error',
type: 'stream_error',
},
})}\n\n`);
res.write('data: [DONE]\n\n');
res.end();
});
return;
}
const message = result.message || {};
const toolCalls = message.tool_calls || normalizeToolCallsFromContent(message.content);
const contentText = extractTextContent(message.content);
res.json({
id: completionId,
object: 'chat.completion',
created,
model,
choices: [
{
index: 0,
message: {
role: message.role || 'assistant',
content: contentText,
...(toolCalls ? { tool_calls: toolCalls } : {}),
},
logprobs: null,
finish_reason: result.finish_reason ?? 'stop',
},
],
usage: buildUsage(result.usage),
});
});
================================================
FILE: src/backend/src/routers/puterai/openai/completions.js
================================================
/*
* 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 .
*/
'use strict';
const crypto = require('node:crypto');
const APIError = require('../../../api/APIError.js');
const eggspress = require('../../../api/eggspress.js');
const { TypedValue } = require('../../../services/drivers/meta/Runtime.js');
const { Context } = require('../../../util/context.js');
const DEFAULT_PROVIDER = 'openai-completion';
const getPromptText = (prompt) => {
if ( prompt === undefined || prompt === null ) {
return '';
}
if ( Array.isArray(prompt) ) {
if ( prompt.length === 0 ) return '';
if ( prompt.length === 1 ) {
if ( typeof prompt[0] !== 'string' ) {
throw APIError.create('field_invalid', {
key: 'prompt',
expected: 'a string',
got: typeof prompt[0],
});
}
return prompt[0];
}
throw APIError.create('field_invalid', {
key: 'prompt',
expected: 'a string or single-item array',
got: `array length ${prompt.length}`,
});
}
if ( typeof prompt !== 'string' ) {
throw APIError.create('field_invalid', {
key: 'prompt',
expected: 'a string',
got: typeof prompt,
});
}
return prompt;
};
const extractMessageText = (message) => {
if ( message === undefined || message === null ) return '';
if ( typeof message === 'string' ) return message;
if ( typeof message !== 'object' ) return '';
if ( Array.isArray(message.content) ) {
return message.content.map((part) => {
if ( typeof part === 'string' ) return part;
if ( part && typeof part.text === 'string' ) return part.text;
if ( part && typeof part.content === 'string' ) return part.content;
return '';
}).join('');
}
if ( typeof message.content === 'string' ) return message.content;
if ( message.content && typeof message.content.text === 'string' ) return message.content.text;
return '';
};
const buildUsage = (usage) => {
const promptTokens = usage?.prompt_tokens ?? usage?.input_tokens ?? 0;
const completionTokens = usage?.completion_tokens ?? usage?.output_tokens ?? 0;
return {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
};
};
const svc_web = Context.get('services').get('web-server');
svc_web.allow_undefined_origin(/^\/puterai\/openai\/v1\/completions(\/.*)?$/);
module.exports = eggspress('/openai/v1/completions', {
auth2: true,
json: true,
jsonCanBeLarge: true,
allowedMethods: ['POST'],
}, async (req, res) => {
// We don't allow apps
if ( Context.get('actor').type.app ) {
throw APIError.create('permission_denied');
}
const body = req.body || {};
const stream = !!body.stream;
const ctx = Context.get();
const services = ctx.get('services');
const svcAiChat = services.get('ai-chat');
let messages = body.messages;
if ( ! messages ) {
const prompt = getPromptText(body.prompt);
messages = [{ role: 'user', content: prompt }];
}
let model = body.model;
if ( ! model ) {
const providerName = body.provider || DEFAULT_PROVIDER;
const provider = svcAiChat.getProvider(providerName);
if ( ! provider ) {
throw APIError.create('field_missing', { key: 'model' });
}
model = provider.getDefaultModel();
}
const completeArgs = {
messages,
model,
stream,
...(body.temperature !== undefined ? { temperature: body.temperature } : {}),
...(body.max_tokens !== undefined ? { max_tokens: body.max_tokens } : {}),
...(body.provider ? { provider: body.provider } : {}),
};
const completionId = `cmpl-${crypto.randomUUID().replace(/-/g, '')}`;
const created = Math.floor(Date.now() / 1000);
const result = await svcAiChat.complete(completeArgs);
if ( stream ) {
if ( ! (result instanceof TypedValue) ) {
throw APIError.create('internal_error', { message: 'expected streaming response' });
}
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
let buffer = '';
let usage = null;
const sendChunk = (text, finishReason = null, extra = {}) => {
const payload = {
id: completionId,
object: 'text_completion',
created,
model,
choices: [
{
text,
index: 0,
logprobs: null,
finish_reason: finishReason,
},
],
...extra,
};
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
const streamValue = result.value;
streamValue.on('data', (chunk) => {
buffer += chunk.toString('utf8');
let newlineIndex;
while ( (newlineIndex = buffer.indexOf('\n')) >= 0 ) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if ( ! line ) continue;
let event;
try {
event = JSON.parse(line);
} catch {
continue;
}
if ( event.type === 'text' && typeof event.text === 'string' ) {
sendChunk(event.text);
}
if ( event.type === 'usage' ) {
usage = event.usage;
}
}
});
streamValue.on('end', () => {
sendChunk('', 'stop', usage ? { usage: buildUsage(usage) } : {});
res.write('data: [DONE]\n\n');
res.end();
});
streamValue.on('error', (err) => {
res.write(`data: ${JSON.stringify({
error: {
message: err?.message || 'stream error',
type: 'stream_error',
},
})}\n\n`);
res.write('data: [DONE]\n\n');
res.end();
});
return;
}
const messageText = extractMessageText(result.message);
const usage = buildUsage(result.usage);
res.json({
id: completionId,
object: 'text_completion',
created,
model,
choices: [
{
text: messageText,
index: 0,
logprobs: null,
finish_reason: result.finish_reason ?? 'stop',
},
],
usage,
});
});
================================================
FILE: src/backend/src/routers/query/app.js
================================================
/*
* 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 .
*/
const eggspress = require('../../api/eggspress');
const { is_valid_uuid4, get_app } = require('../../helpers');
const express = require('express');
const { fuzz_number } = require('../../util/fuzz');
const { DB_READ } = require('../../services/database/consts');
const PREFIX_APP_UID = 'app-';
module.exports = eggspress('/query/app', {
subdomain: 'api',
auth: true,
verified: true,
fs: true,
mw: [express.json({ extended: true })],
allowedMethods: ['POST'],
}, async (req, res, _next) => {
const results = [];
const db = req.services.get('database').get(DB_READ, 'apps');
const svc_appInformation = req.services.get('app-information');
const app_list = [...req.body];
for ( let i = 0 ; i < app_list.length ; i++ ) {
const P = 'collection:';
if ( app_list[i].startsWith(P) ) {
let [col_name, amount] = app_list[i].slice(P.length).split(':');
if ( amount === undefined ) amount = 20;
let uids = svc_appInformation.collections?.[col_name] ?? [];
uids = uids.slice(0, Math.min(uids.length, amount));
app_list.splice(i, 1, ...uids);
}
}
for ( let i = 0 ; i < app_list.length ; i++ ) {
const P = 'tag:';
if ( app_list[i].startsWith(P) ) {
let [tag_name, amount] = app_list[i].slice(P.length).split(':');
if ( amount === undefined ) amount = 20;
let uids = svc_appInformation.tags[tag_name] ?? [];
uids = uids.slice(0, Math.min(uids.length, amount));
app_list.splice(i, 1, ...uids);
}
}
for ( const app_selector_raw of app_list ) {
const app_selector =
app_selector_raw.startsWith(PREFIX_APP_UID) &&
is_valid_uuid4(app_selector_raw.slice(PREFIX_APP_UID.length))
? { uid: app_selector_raw }
: { name: app_selector_raw }
;
const app = await get_app(app_selector);
if ( ! app ) continue;
// uuid, name, title, description, icon, created, filetype_associations, number of users
// emit event for extra data gathering
const extraDataEventObject = Object.fromEntries(app_list.map((appId) => [appId, {}]));
await req.services.get('event').emit('apps.queried.extra', extraDataEventObject);
// TODO: cache
const associations = []; {
const res_associations = await db.read(
'SELECT * FROM app_filetype_association WHERE app_id = ?',
[app.id],
);
for ( const row of res_associations ) {
associations.push(row.type);
}
}
const stats = await svc_appInformation.get_stats(app.uid);
for ( const k in stats ) stats[k] = fuzz_number(stats[k]);
delete stats.open_count;
// TODO: imply from app model
results.push({
uuid: app.uid,
name: app.name,
title: app.title,
// icon: app.icon,
description: app.description,
metadata: app.metadata,
tags: app.tags ? app.tags.split(',') : [],
created: app.timestamp,
associations,
...stats,
...extraDataEventObject[app.uid],
});
}
res.send(results);
});
================================================
FILE: src/backend/src/routers/recentAppOpens/RecentAppOpensRedisCacheSpace.js
================================================
/*
* 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 .
*/
const RecentAppOpensRedisCacheSpace = {
key: userId => `app_opens:user:${userId}`,
};
export { RecentAppOpensRedisCacheSpace };
================================================
FILE: src/backend/src/routers/recentAppOpens/rao.js
================================================
/*
* 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 .
*/
// records app opens
'use strict';
const express = require('express');
const router = express.Router();
const config = require('../../config');
const { is_valid_uuid4, get_app } = require('../../helpers');
const { DB_WRITE } = require('../../services/database/consts.js');
const configurable_auth = require('../../middleware/configurable_auth.js');
const { UserActorType, AppUnderUserActorType } = require('../../services/auth/Actor.js');
const APIError = require('../../api/APIError.js');
const { redisClient } = require('../../clients/redis/redisSingleton');
const { setRedisCacheValue } = require('../../clients/redis/cacheUpdate.js');
const { RecentAppOpensRedisCacheSpace } = require('./RecentAppOpensRedisCacheSpace.js');
// -----------------------------------------------------------------------//
// POST /rao
// -----------------------------------------------------------------------//
router.post('/rao', configurable_auth(), express.json(), async (req, res, next) => {
const { actor } = req;
// check subdomain
if ( require('../../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
let app_uid;
if ( actor.type instanceof UserActorType ) {
// validation
if ( !req.body.app_uid || typeof req.body.app_uid !== 'string' && !(req.body.app_uid instanceof String) )
{
return res.status(400).send({ code: 'invalid_app_uid', message: 'Invalid app uid' });
}
// must be a valid uuid
// app uuids start with 'app-', so in order to validate them we remove the prefix first
else if ( ! is_valid_uuid4(req.body.app_uid.replace('app-', '')) )
{
return res.status(400).send({ code: 'invalid_app_uid', message: 'Invalid app uid' });
}
app_uid = req.body.app_uid;
} else if ( actor.type instanceof AppUnderUserActorType ) {
app_uid = actor.type.app.uid;
} else {
throw APIError.create('forbidden');
}
// get db connection
const db = req.services.get('database').get(DB_WRITE, 'apps');
// insert into db
db.write(
'INSERT INTO app_opens (app_uid, user_id, ts) VALUES (?, ?, ?)',
[app_uid, req.user.id, Math.floor(new Date().getTime() / 1000)],
);
// get app
const opened_app = await get_app({ uid: app_uid });
// send process event `puter.app_open`
process.emit('puter.app_open', {
app_uid: app_uid,
user_id: req.user.id,
app_owner_user_id: opened_app.owner_user_id,
ts: Math.floor(new Date().getTime() / 1000),
});
// -----------------------------------------------------------------------//
// Update the 'app opens' cache
// -----------------------------------------------------------------------//
// First try the cache to see if we have recent apps
let recent_apps;
const recent_apps_raw = await redisClient.get(RecentAppOpensRedisCacheSpace.key(req.user.id));
if ( recent_apps_raw ) {
try {
recent_apps = JSON.parse(recent_apps_raw);
} catch ( e ) {
recent_apps = null;
}
}
// If cache is not empty, prepend it with the new app
if ( recent_apps && Array.isArray(recent_apps) && recent_apps.length > 0 ) {
// add the app to the beginning of the array
recent_apps.unshift({ app_uid: app_uid });
// dedupe the array
recent_apps = recent_apps.filter((v, i, a) => a.findIndex(t => (t.app_uid === v.app_uid)) === i);
// limit to 10
recent_apps = recent_apps.slice(0, 10);
// update cache
await setRedisCacheValue(
RecentAppOpensRedisCacheSpace.key(req.user.id),
JSON.stringify(recent_apps),
{ eventData: recent_apps },
);
}
// Cache is empty, query the db and update the cache
else {
db.read(
'SELECT DISTINCT app_uid FROM app_opens WHERE user_id = ? GROUP BY app_uid ORDER BY MAX(_id) DESC LIMIT 10',
[req.user.id],
).then(async ([apps]) => {
// Update cache with the results from the db (if any results were returned)
if ( apps && Array.isArray(apps) && apps.length > 0 ) {
await setRedisCacheValue(
RecentAppOpensRedisCacheSpace.key(req.user.id),
JSON.stringify(apps),
{ eventData: apps },
);
}
});
}
// Update clients
const svc_socketio = req.services.get('socketio');
svc_socketio.send({ room: req.user.id }, 'app.opened', {
uuid: opened_app.uid,
uid: opened_app.uid,
name: opened_app.name,
title: opened_app.title,
icon: opened_app.icon,
godmode: opened_app.godmode,
maximize_on_start: opened_app.maximize_on_start,
index_url: opened_app.index_url,
original_client_socket_id: req.body.original_client_socket_id,
});
// return
return res.status(200).send({ code: 'ok', message: 'ok' });
});
module.exports = router;
================================================
FILE: src/backend/src/routers/remove-site-dir.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth.js');
const config = require('../config');
// -----------------------------------------------------------------------//
// POST /remove-site-dir
// -----------------------------------------------------------------------//
router.post('/remove-site-dir', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// validation
if ( req.body.dir_uuid === undefined )
{
return res.status(400).send('dir_uuid is required');
}
// modules
const { uuid2fsentry, chkperm } = require('../helpers');
const db = require('../db/mysql.js');
const user = req.user;
const item = await uuid2fsentry(req.body.dir_uuid);
if ( item !== false ) {
// check permission
if ( ! await chkperm(item, req.user.id, 'write') )
{
return res.status(403).send({ code: 'forbidden', message: 'permission denied.' });
}
// remove dir/subdomain connection
if ( req.body.site_uuid )
{
await db.promise().execute(
'UPDATE subdomains SET root_dir_id = NULL WHERE user_id = ? AND root_dir_id =? AND uuid = ?',
[user.id, item.id, req.body.site_uuid]);
}
// if site_uuid is undefined, disassociate all websites from this directory
else
{
await db.promise().execute(
'UPDATE subdomains SET root_dir_id = NULL WHERE user_id = ? AND root_dir_id =?',
[user.id, item.id]);
}
res.send({});
} else {
res.status(400).send();
}
});
module.exports = router;
================================================
FILE: src/backend/src/routers/removeItem.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth.js');
const config = require('../config');
// -----------------------------------------------------------------------//
// POST /removeItem
// -----------------------------------------------------------------------//
router.post('/removeItem', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// validation
if ( ! req.body.key )
{
return res.status(400).send('`key` is required');
}
// check size of key, if it's too big then it's an invalid key and we don't want to waste time on it
else if ( Buffer.byteLength(req.body.key, 'utf8') > config.kv_max_key_size )
{
return res.status(400).send('`key` is too long.');
}
else if ( ! req.body.app )
{
return res.status(400).send('`app` is required');
}
// modules
const db = require('../db/mysql.js');
// get murmurhash module
const murmurhash = require('murmurhash');
// hash key for faster search in DB
const key_hash = murmurhash.v3(req.body.key);
// insert into DB
let [kv] = await db.promise().execute(
'DELETE FROM kv WHERE user_id=? AND app = ? AND kkey_hash = ? LIMIT 1',
[
req.user.id,
req.body.app ?? 'global',
key_hash,
]);
// send results to client
return res.send({});
});
module.exports = router;
================================================
FILE: src/backend/src/routers/save_account.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
const {
get_taskbar_items, username_exists, send_email_verification_code, send_email_verification_token, invalidate_cached_user, get_user,
is_user_signup_disabled: lazy_user_signup,
} = require('../helpers');
const auth = require('../middleware/auth.js');
const config = require('../config');
const { DB_WRITE } = require('../services/database/consts');
const SECOND = 1000;
// -----------------------------------------------------------------------//
// POST /save_account
// -----------------------------------------------------------------------//
router.post('/save_account', auth, express.json(), async (req, res, next) => {
// either api. subdomain or no subdomain
if ( require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '' )
{
next();
}
const is_user_signup_disabled = await lazy_user_signup();
if ( is_user_signup_disabled ) {
return res.status(403).send('User signup is disabled.');
}
// modules
const db = req.services.get('database').get(DB_WRITE, 'auth');
const validator = require('validator');
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
// validation
if ( req.user.password !== null )
{
return res.status(400).send('User account already saved.');
}
else if ( ! req.body.username )
{
return res.status(400).send('Username is required');
}
// username must be a string
else if ( typeof req.body.username !== 'string' )
{
return res.status(400).send('username must be a string.');
}
else if ( ! req.body.username.match(config.username_regex) )
{
return res.status(400).send('Username can only contain letters, numbers and underscore (_).');
}
else if ( req.body.username.length > config.username_max_length )
{
return res.status(400).send(`Username cannot have more than ${config.username_max_length} characters.`);
}
// check if username matches any reserved words
else if ( config.reserved_words.includes(req.body.username) )
{
return res.status(400).send({ message: 'This username is not available.' });
}
else if ( ! req.body.email )
{
return res.status(400).send('Email is required');
}
// email must be a string
else if ( typeof req.body.email !== 'string' )
{
return res.status(400).send('email must be a string.');
}
else if ( ! validator.isEmail(req.body.email) )
{
return res.status(400).send('Please enter a valid email address.');
}
else if ( ! req.body.password )
{
return res.status(400).send('Password is required');
}
// password must be a string
else if ( typeof req.body.password !== 'string' )
{
return res.status(400).send('password must be a string.');
}
else if ( req.body.password.length < config.min_pass_length )
{
return res.status(400).send(`Password must be at least ${config.min_pass_length} characters long.`);
}
const svc_cleanEmail = req.services.get('clean-email');
const clean_email = svc_cleanEmail.clean(req.body.email);
if ( ! await svc_cleanEmail.validate(clean_email) ) {
return res.status(400).send('This email does not seem to be valid.');
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('save-account') ) {
return res.status(429).send('Too many requests.');
}
const svc_lock = req.services.get('lock');
return svc_lock.lock([
`save-account:username:${req.body.username}`,
`save-account:email:${req.body.email}`,
], { timeout: 5 * SECOND }, async () => {
// duplicate username check, do this only if user has supplied a new username
if ( req.body.username !== req.user.username && await username_exists(req.body.username) )
{
return res.status(400).send('This username already exists in our database. Please use another one.');
}
// duplicate email check (pseudo-users don't count)
let rows2 = await db.read('SELECT EXISTS(SELECT 1 FROM user WHERE email=? AND password IS NOT NULL) AS email_exists', [req.body.email]);
if ( rows2[0].email_exists )
{
return res.status(400).send('This email already exists in our database. Please use another one.');
}
// get pseudo user, if exists
let pseudo_user = await db.read('SELECT * FROM user WHERE email = ? AND password IS NULL', [req.body.email]);
pseudo_user = pseudo_user[0];
// send_confirmation_code
req.body.send_confirmation_code = req.body.send_confirmation_code ?? true;
// todo email confirmation is required by default unless:
// Pseudo user converting and matching uuid is provided
let email_confirmation_required = 0;
// -----------------------------------
// Get referral user
// -----------------------------------
let referred_by_user = undefined;
if ( req.body.referral_code ) {
referred_by_user = await get_user({ referral_code: req.body.referral_code });
if ( ! referred_by_user ) {
return res.status(400).send('Referral code not found');
}
}
// -----------------------------------
// New User
// -----------------------------------
const user_uuid = req.user.uuid;
let email_confirm_code = Math.floor(100000 + Math.random() * 900000);
const email_confirm_token = uuidv4();
if ( pseudo_user === undefined ) {
await db.write(
`UPDATE user
SET
username = ?, email = ?, password = ?, email_confirm_code = ?, email_confirm_token = ?${
referred_by_user ? ', referred_by = ?' : '' }
WHERE
id = ?`,
[
// username
req.body.username,
// email
req.body.email,
// password
await bcrypt.hash(req.body.password, 8),
// email_confirm_code
`${ email_confirm_code}`,
//email_confirm_token
email_confirm_token,
// referred_by
...(referred_by_user ? [referred_by_user.id] : []),
// id
req.user.id,
],
);
invalidate_cached_user(req.user);
// Update root directory name
await db.write(
'UPDATE fsentries SET name = ?, path = ? WHERE user_id = ? and parent_uid IS NULL',
[
// name
req.body.username,
`/${ req.body.username}`,
// id
req.user.id,
],
);
const filesystem = req.services.get('filesystem');
await filesystem.update_child_paths(`/${req.user.username}`, `/${req.body.username}`, req.user.id);
if ( req.body.send_confirmation_code )
{
send_email_verification_code(email_confirm_code, req.body.email);
}
else
{
send_email_verification_token(email_confirm_token, req.body.email, user_uuid);
}
}
// create token for login: session token for cookie, GUI token for client
const svc_auth = req.services.get('auth');
const { session, token: session_token } = await svc_auth.create_session_token(req.user, { req });
const gui_token = svc_auth.create_gui_token(req.user, session);
// user id
// todo if pseudo user, assign directly no need to do another DB lookup
const user_id = req.user.id;
const user_res = await db.read('SELECT * FROM `user` WHERE `id` = ? LIMIT 1', [user_id]);
const user = user_res[0];
// todo send LINK-based verification email
// HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie)
res.cookie(config.cookie_name, session_token);
{
const svc_event = req.services.get('event');
svc_event.emit('user.save_account', { user });
}
// return results
return res.send({
token: gui_token,
user: {
username: user.username,
uuid: user.uuid,
email: user.email,
is_temp: false,
requires_email_confirmation: user.requires_email_confirmation,
email_confirmed: user.email_confirmed,
email_confirmation_required: email_confirmation_required,
taskbar_items: await get_taskbar_items(user),
referral_code: user.referral_code,
},
});
});
});
module.exports = router;
================================================
FILE: src/backend/src/routers/send-confirm-email.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
const auth = require('../middleware/auth.js');
const { send_email_verification_code, invalidate_cached_user } = require('../helpers');
const { DB_WRITE } = require('../services/database/consts.js');
// -----------------------------------------------------------------------//
// POST /send-confirm-email
// -----------------------------------------------------------------------//
router.post('/send-confirm-email', auth, express.json(), async (req, res, next) => {
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('send-confirm-email') ) {
return res.status(429).send('Too many requests.');
}
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
const db = req.services.get('database').get(DB_WRITE, 'auth');
let email_confirm_code = Math.floor(100000 + Math.random() * 900000);
if ( req.user.suspended )
{
return res.status(401).send({ error: 'Account suspended' });
}
await db.write(
'UPDATE user SET email_confirm_code = ? WHERE id = ?',
[
// email_confirm_code
`${email_confirm_code}`,
// id
req.user.id,
],
);
await invalidate_cached_user(req.user);
// send email verification
send_email_verification_code(email_confirm_code, req.user.email);
res.send();
});
module.exports = router;
================================================
FILE: src/backend/src/routers/send-pass-recovery-email.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
const { body_parser_error_handler, get_user, invalidate_cached_user } = require('../helpers');
const config = require('../config');
const { DB_WRITE } = require('../services/database/consts');
const jwt = require('jsonwebtoken');
// -----------------------------------------------------------------------//
// POST /send-pass-recovery-email
// -----------------------------------------------------------------------//
router.post('/send-pass-recovery-email', express.json(), body_parser_error_handler, async (req, res, next) => {
// either api. subdomain or no subdomain
if ( require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '' )
{
next();
}
// modules
const db = req.services.get('database').get(DB_WRITE, 'auth');
const validator = require('validator');
// validation
if ( !req.body.username && !req.body.email )
{
return res.status(400).send('Username or email is required.');
}
// username, if provided, must be a string
else if ( req.body.username && typeof req.body.username !== 'string' )
{
return res.status(400).send('username must be a string.');
}
// if username doesn't pass regex test it's invalid anyway, no need to do DB lookup
else if ( req.body.username && !req.body.username.match(config.username_regex) )
{
return res.status(400).send('Invalid username.');
}
// email, if provided, must be a string
else if ( req.body.email && typeof req.body.email !== 'string' )
{
return res.status(400).send('email must be a string.');
}
// if email is invalid, no need to do DB lookup anyway
else if ( req.body.email && !validator.isEmail(req.body.email) )
{
return res.status(400).send('Invalid email.');
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('send-pass-recovery-email') ) {
return res.status(429).send('Too many requests.');
}
try {
let user;
// see if username exists
if ( req.body.username ) {
user = await get_user({ username: req.body.username });
if ( ! user )
{
return res.status(400).send('Username not found.');
}
}
// see if email exists
else if ( req.body.email ) {
user = await get_user({ email: req.body.email });
if ( ! user )
{
return res.status(400).send('Email not found.');
}
}
if ( user.username === 'system' && config.allow_system_login !== true ) {
return res.status(400).send(
req.body.username
? 'Username not found.'
: 'Email not found.',
);
}
// check if user is suspended
if ( user.suspended ) {
return res.status(401).send('Account suspended');
}
// check if user even has an email for recovery
if ( ! user.email ) {
return res.status(422).send('No email associated with this account.');
}
// set pass_recovery_token
const { v4: uuidv4 } = require('uuid');
const token = uuidv4();
await db.write(
'UPDATE user SET pass_recovery_token=? WHERE `id` = ?',
[token, user.id],
);
invalidate_cached_user(user);
// create jwt
const jwt_token = jwt.sign({
user_uid: user.uuid,
token,
// email change invalidates password recovery
email: user.email,
}, config.jwt_secret, { expiresIn: '1h' });
// create link
const rec_link = `${config.origin }/action/set-new-password?token=${ jwt_token}`;
const svc_email = req.services.get('email');
await svc_email.send_email({ email: user.email }, 'email_password_recovery', {
link: rec_link,
});
// Send response
if ( req.body.username )
{
return res.send({ message: `Password recovery sent to the email associated with ${user.username} . Please check your email for instructions on how to reset your password.` });
}
else
{
return res.send({ message: `Password recovery email sent to ${user.email} . Please check your email for instructions on how to reset your password.` });
}
} catch (e) {
console.log(e);
return res.status(400).send(e);
}
});
module.exports = router;
================================================
FILE: src/backend/src/routers/set-desktop-bg.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const config = require('../config.js');
const { invalidate_cached_user } = require('../helpers');
const router = new express.Router();
const auth = require('../middleware/auth.js');
const { DB_WRITE } = require('../services/database/consts.js');
// -----------------------------------------------------------------------//
// POST /set-desktop-bg
// -----------------------------------------------------------------------//
router.post('/set-desktop-bg', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// modules
const db = req.services.get('database').get(DB_WRITE, 'ui');
// insert into DB
await db.write(
'UPDATE user SET desktop_bg_url = ?, desktop_bg_color = ?, desktop_bg_fit = ? WHERE user.id = ?',
[
req.body.url ?? null,
req.body.color ?? null,
req.body.fit ?? null,
req.user.id,
],
);
invalidate_cached_user(req.user);
// send results to client
return res.send({});
});
module.exports = router;
================================================
FILE: src/backend/src/routers/set-pass-using-token.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
const config = require('../config');
const { invalidate_cached_user_by_id, get_user } = require('../helpers');
const { DB_WRITE } = require('../services/database/consts');
const jwt = require('jsonwebtoken');
// Ensure we don't expose branches with differing messages.
const SAFE_NEGATIVE_RESPONSE = 'This password recovery token is no longer valid.';
// -----------------------------------------------------------------------//
// POST /set-pass-using-token
// -----------------------------------------------------------------------//
router.post('/set-pass-using-token', express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '' )
{
next();
}
// modules
const bcrypt = require('bcrypt');
const db = req.services.get('database').get(DB_WRITE, 'auth');
// password is required
if ( ! req.body.password )
{
return res.status(401).send('password is required');
}
// token is required
else if ( ! req.body.token )
{
return res.status(401).send('token is required');
}
// password must be a string
else if ( typeof req.body.password !== 'string' )
{
return res.status(400).send('password must be a string.');
}
// check password length
else if ( req.body.password.length < config.min_pass_length )
{
return res.status(400).send(`Password must be at least ${config.min_pass_length} characters long.`);
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('set-pass-using-token') ) {
return res.status(429).send('Too many requests.');
}
const { token, user_uid, email } = jwt.verify(req.body.token, config.jwt_secret);
const user = await get_user({ uuid: user_uid, force: true });
if ( user.email !== email ) {
return res.status(400).send(SAFE_NEGATIVE_RESPONSE);
}
try {
const info = await db.write(
'UPDATE user SET password=?, pass_recovery_token=NULL, change_email_confirm_token=NULL WHERE `uuid` = ? AND pass_recovery_token = ?',
[await bcrypt.hash(req.body.password, 8), user_uid, token],
);
if ( ! info?.anyRowsAffected ) {
return res.status(400).send(SAFE_NEGATIVE_RESPONSE);
}
invalidate_cached_user_by_id(user.id);
return res.send('Password successfully updated.');
} catch (e) {
return res.status(500).send('An internal error occured.');
}
});
module.exports = router;
================================================
FILE: src/backend/src/routers/set_layout.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth.js');
const config = require('../config');
const { DB_WRITE } = require('../services/database/consts.js');
// -----------------------------------------------------------------------//
// POST /set_layout
// -----------------------------------------------------------------------//
router.post('/set_layout', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// validation
if ( req.body.item_uid === undefined && req.body.item_path === undefined )
{
return res.status(400).send('`item_uid` or `item_path` is required');
}
else if ( req.body.layout === undefined )
{
return res.status(400).send('`layout` is required');
}
else if ( req.body.layout !== 'icons' && req.body.layout !== 'details' && req.body.layout !== 'list' )
{
return res.status(400).send('invalid `layout`');
}
// modules
const db = req.services.get('database').get(DB_WRITE, 'ui');
const { uuid2fsentry, convert_path_to_fsentry, chkperm } = require('../helpers');
//get dir
let item;
if ( req.body.item_uid )
{
item = await uuid2fsentry(req.body.item_uid);
}
else if ( req.body.item_path )
{
item = await convert_path_to_fsentry(req.body.item_path);
}
// item not found
if ( item === false ) {
return res.status(400).send({
error: {
message: 'No entry found with this uid',
},
});
}
// must be dir
if ( ! item.is_dir )
{
return res.status(400).send('must be a directory');
}
// check permission
if ( ! await chkperm(item, req.user.id, 'write') )
{
return res.status(403).send({ code: 'forbidden', message: 'permission denied.' });
}
// insert into DB
await db.write('UPDATE fsentries SET layout = ? WHERE id = ?',
[req.body.layout, item.id]);
// send results to client
return res.send({});
});
module.exports = router;
================================================
FILE: src/backend/src/routers/set_sort_by.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth.js');
const config = require('../config');
const { DB_WRITE } = require('../services/database/consts.js');
// -----------------------------------------------------------------------//
// POST /set_sort_by
// -----------------------------------------------------------------------//
router.post('/set_sort_by', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// validation
if ( req.body.item_uid === undefined && req.body.item_path === undefined )
{
return res.status(400).send('`item_uid` or `item_path` is required');
}
else if ( req.body.sort_by === undefined )
{
return res.status(400).send('`sort_by` is required');
}
else if ( req.body.sort_by !== 'name' && req.body.sort_by !== 'size' && req.body.sort_by !== 'modified' && req.body.sort_by !== 'type' )
{
return res.status(400).send('invalid `sort_by`');
}
else if ( req.body.sort_order !== 'asc' && req.body.sort_order !== 'desc' )
{
return res.status(400).send('invalid `sort_order`');
}
// modules
const db = req.services.get('database').get(DB_WRITE, 'ui');
const { uuid2fsentry, convert_path_to_fsentry, chkperm } = require('../helpers');
//get dir
let item;
if ( req.body.item_uid )
{
item = await uuid2fsentry(req.body.item_uid);
}
else if ( req.body.item_path )
{
item = await convert_path_to_fsentry(req.body.item_path);
}
// item not found
if ( item === false ) {
return res.status(400).send({
error: {
message: 'No entry found with this uid',
},
});
}
// must be dir
if ( ! item.is_dir )
{
return res.status(400).send('must be a directory');
}
// check permission
if ( ! await chkperm(item, req.user.id, 'write') )
{
return res.status(403).send({ code: 'forbidden', message: 'permission denied.' });
}
// set sort_by
await db.write('UPDATE fsentries SET sort_by = ? WHERE id = ?',
[req.body.sort_by, item.id]);
// set sort_order
await db.write('UPDATE fsentries SET sort_order = ? WHERE id = ?',
[req.body.sort_order, item.id]);
// send results to client
return res.send({});
});
module.exports = router;
================================================
FILE: src/backend/src/routers/sign.js
================================================
/*
* 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 .
*/
'use strict';
const { sign_file, get_app } = require('../helpers');
const eggspress = require('../api/eggspress.js');
const APIError = require('../api/APIError.js');
const { Context } = require('../util/context.js');
const { UserActorType, AppUnderUserActorType } = require('../services/auth/Actor.js');
const { NodePathSelector } = require('../filesystem/node/selectors.js');
// -----------------------------------------------------------------------//
// POST /sign
// -----------------------------------------------------------------------//
module.exports = eggspress('/sign', {
subdomain: 'api',
auth2: true,
verified: true,
json: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const actor = Context.get('actor');
const svc_fs = Context.get('services').get('filesystem');
if ( ! req.body.items ) {
throw APIError.create('field_missing', null, { key: 'items' });
}
let items = Array.isArray(req.body.items) ? req.body.items : [res];
let signatures = [];
// Static request validation happens first
for ( const item of items ) {
if ( ! item ) {
throw APIError.create('field_invalid', null, {
key: 'items',
expected: 'each item to have: (uid OR path) AND action',
}).serialize();
}
if ( typeof item !== 'object' || Array.isArray(item) ) {
throw APIError.create('field_invalid', null, {
key: 'items',
expected: 'each item to be an object',
}).serialize();
}
// validation
if ( (!item.uid && !item.path) || !item.action ) {
throw APIError.create('field_invalid', null, {
key: 'items',
expected: 'each item to have: (uid OR path) AND action',
}).serialize();
}
if ( typeof item.uid !== 'string' && typeof item.path !== 'string' ) {
throw APIError.create('field_invalid', null, {
key: 'items',
expected: 'each item to have only string values for uid and path',
}).serialize();
}
}
// Usually, only users can sign
if ( ! (actor.type instanceof UserActorType) ) {
if ( ! (actor.type instanceof AppUnderUserActorType) ) {
throw APIError.create('forbidden');
}
// But, apps can sign files in their own AppData directory
for ( const item of req.body.items ) {
const node = await svc_fs.node(item);
const appdata_path = `/${actor.type.user.username}/AppData/${actor.type.app.uid}`;
const appdata_node = await svc_fs.node(new NodePathSelector(appdata_path));
if ( ! appdata_node.is_above(node) ) {
throw APIError.create('forbidden');
}
}
}
const result = {
signatures,
};
let app = null;
if ( req.body.app_uid ) {
if ( typeof req.body.app_uid !== 'string' ) {
throw APIError.create('field_invalid', null, {
key: 'app_uid',
expected: 'string',
});
}
app = await get_app({ uid: req.body.app_uid });
if ( ! app ) {
// FIXME: subject.entry.name isn't available here
throw APIError.create('no_suitable_app', null); //, { entry_name: subject.entry.name });
}
// Generate user-app token
const svc_auth = Context.get('services').get('auth');
const token = await svc_auth.get_user_app_token(app.uid);
result.token = token;
}
for ( const item of items ) {
const node = await svc_fs.node(item);
if ( ! await node.exists() ) {
// throw APIError.create('subject_does_not_exist').serialize()
signatures.push({});
continue;
}
const svc_acl = Context.get('services').get('acl');
if ( ! await svc_acl.check(actor, node, 'read') ) {
throw await svc_acl.get_safe_acl_error(actor, node, 'read');
}
if ( item.action === 'write' ) {
if ( ! await svc_acl.check(actor, node, 'write') ) {
item.action = 'read';
}
}
if ( app !== null ) {
// Grant write permission to app
const svc_permission = Context.get('services').get('permission');
const permission = `fs:${await node.get('uid')}:write`;
await svc_permission.grant_user_app_permission(actor, app.uid, permission, {}, { reason: 'endpoint:sign' });
}
// sign
try {
let signature = await sign_file(node.entry, item.action);
signature.path = signature.path ?? item.path ?? await node.get('path');
signatures.push(signature);
}
catch (e) {
signatures.push({});
}
}
res.send(result);
});
================================================
FILE: src/backend/src/routers/signup.js
================================================
/*
* 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 .
*/
'use strict';
const { get_taskbar_items, send_email_verification_code, send_email_verification_token, username_exists, invalidate_cached_user_by_id, get_user } = require('../helpers');
const config = require('../config');
const eggspress = require('../api/eggspress');
const { Context } = require('../util/context');
const { DB_WRITE } = require('../services/database/consts');
const { generate_identifier } = require('../util/identifier');
const { is_temp_users_disabled: lazy_temp_users,
is_user_signup_disabled: lazy_user_signup } = require('../helpers');
const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middleware');
async function generate_random_username () {
let username;
do {
username = generate_identifier();
} while ( await username_exists(username) );
return username;
}
// -----------------------------------------------------------------------//
// POST /signup
// -----------------------------------------------------------------------//
module.exports = eggspress(['/signup'], {
allowedMethods: ['POST'],
alarm_timeout: 7000, // when it calls us
response_timeout: 20000, // when it gives up
abuse: {
no_bots: true,
// puter_origin: false,
shadow_ban_responder: (req, res) => {
res.status(400).send('email username mismatch; please provide a password');
},
},
mw: [requireCaptcha({ strictMode: true, eventType: 'signup' })], // Conditionally require captcha for signup
}, async (req, res, next) => {
// either api. subdomain or no subdomain
if ( require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '' )
{
next();
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('signup') ) {
return res.status(429).send('Too many requests.');
}
// modules
const db = req.services.get('database').get(DB_WRITE, 'auth');
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
const validator = require('validator');
let uuid_user;
const svc_auth = Context.get('services').get('auth');
const svc_authAudit = Context.get('services').get('auth-audit');
svc_authAudit.record({
requester: Context.get('requester'),
action: req.body.is_temp ? 'signup:temp' : 'signup:real',
body: req.body,
});
// check bot trap, if `p102xyzname` is anything but an empty string it means
// that a bot has filled the form
// doesn't apply to temp users
if ( !req.body.is_temp && req.body.p102xyzname !== '' )
{
return res.send();
}
// cloudflare turnstile validation
//
// ref: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
if ( config.services?.['cloudflare-turnstile']?.enabled ) {
const formData = new FormData();
formData.append('secret', config.services?.['cloudflare-turnstile']?.secret_key);
formData.append('response', req.body['cf-turnstile-response']);
formData.append('remoteip', req.headers['x-forwarded-for'] || req.connection.remoteAddress);
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData,
});
const result = await response.json();
if ( ! result.success )
{
return res.status(400).send('captcha verification failed');
}
}
// send event
let event = {
allow: true,
ip: req.headers?.['x-forwarded-for'] ||
req.connection?.remoteAddress,
user_agent: req.headers?.['user-agent'],
body: req.body,
};
const svc_event = Context.get('services').get('event');
await svc_event.emit('puter.signup', event);
if ( ! event.allow ) {
return res.status(400).send({ message: event.error ?? 'You are not allowed to sign up.', code: 'not_allowed_to_signup' });
}
// check if user is already logged in
if ( req.body.is_temp && req.cookies[config.cookie_name] ) {
const { user, token } = await svc_auth.check_session(req.cookies[config.cookie_name]);
res.cookie(config.cookie_name, token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
// const decoded = await jwt.verify(token, config.jwt_secret);
// const user = await get_user({ uuid: decoded.uuid });
if ( user ) {
return res.send({
token: token,
user: {
username: user.username,
uuid: user.uuid,
email: user.email,
email_confirmed: user.email_confirmed,
requires_email_confirmation: user.requires_email_confirmation,
is_temp: (user.password === null && user.email === null),
taskbar_items: await get_taskbar_items(user),
},
});
}
}
const is_temp_users_disabled = await lazy_temp_users();
const is_user_signup_disabled = await lazy_user_signup();
if ( is_temp_users_disabled && is_user_signup_disabled ) {
return res.status(403).send({ message: 'User signup and Temporary users are disabled.', code: 'user_signup_and_temp_users_disabled' });
}
if ( !req.body.is_temp && is_user_signup_disabled ) {
return res.status(403).send({ message: 'User signup is disabled.', code: 'user_signup_disabled' });
}
if ( req.body.is_temp && is_temp_users_disabled ) {
return res.status(403).send({ message: 'Temporary users are disabled.', code: 'temp_users_disabled' });
}
if ( req.body.is_temp && event.no_temp_user ) {
return res.status(403).send({ message: 'You must login or signup.', code: 'must_login_or_signup' });
}
// Create temp user data
req.body.username = req.body.username ?? await generate_random_username();
req.body.email = req.body.email ?? `${req.body.username }@gmail.com`;
req.body.password = req.body.password ?? 'sadasdfasdfsadfsa';
// send_confirmation_code
req.body.send_confirmation_code = req.body.send_confirmation_code ?? true;
// username is required
if ( ! req.body.username )
{
return res.status(400).send('Username is required');
}
// username must be a string
else if ( typeof req.body.username !== 'string' )
{
return res.status(400).send('username must be a string.');
}
// check if username is valid
else if ( ! req.body.username.match(config.username_regex) )
{
return res.status(400).send('Username can only contain letters, numbers and underscore (_).');
}
// check if username is of proper length
else if ( req.body.username.length > config.username_max_length )
{
return res.status(400).send(`Username cannot be longer than ${config.username_max_length} characters.`);
}
// check if username matches any reserved words
else if ( config.reserved_words.includes(req.body.username) )
{
return res.status(400).send({ message: 'This username is not available.' });
}
// TODO: DRY: change_email.js
else if ( !req.body.is_temp && !req.body.email )
{
return res.status(400).send('Email is required');
}
// email, if present, must be a string
else if ( req.body.email && typeof req.body.email !== 'string' )
{
return res.status(400).send('email must be a string.');
}
// if email is present, validate it
else if ( !req.body.is_temp && !validator.isEmail(req.body.email) )
{
return res.status(400).send('Please enter a valid email address.');
}
else if ( !req.body.is_temp && !req.body.password )
{
return res.status(400).send('Password is required');
}
// password, if present, must be a string
else if ( req.body.password && typeof req.body.password !== 'string' )
{
return res.status(400).send('password must be a string.');
}
else if ( !req.body.is_temp && req.body.password.length < config.min_pass_length )
{
return res.status(400).send(`Password must be at least ${config.min_pass_length} characters long.`);
}
const svc_cleanEmail = req.services.get('clean-email');
const clean_email = svc_cleanEmail.clean(req.body.email);
if ( !req.body.is_temp && !await svc_cleanEmail.validate(clean_email) ) {
return res.status(400).send('This email does not seem to be valid.');
}
// duplicate username check
if ( await username_exists(req.body.username) )
{
return res.status(400).send('This username already exists in our database. Please use another one.');
}
// Email check is here :: Add condition for email_confirmed=1
// duplicate email check (pseudo-users don't count)
let rows2 = await db.read(`SELECT EXISTS(
SELECT 1 FROM user WHERE (email=? OR clean_email=?) AND email_confirmed=1 AND password IS NOT NULL
) AS email_exists`, [req.body.email, clean_email]);
if ( rows2[0].email_exists )
{
return res.status(400).send('This email already exists in our database. Please use another one.');
}
// get pseudo user, if exists
let pseudo_user = await db.read('SELECT * FROM user WHERE email = ? AND password IS NULL', [req.body.email]);
pseudo_user = pseudo_user[0];
// get uuid user, if exists
if ( req.body.uuid ) {
uuid_user = await db.read('SELECT * FROM user WHERE uuid = ? LIMIT 1', [req.body.uuid]);
uuid_user = uuid_user[0];
}
// email confirmation is not required by default
let email_confirmation_required = 0;
// Pseudo user converting and matching uuid is provided
if ( pseudo_user && uuid_user && pseudo_user.id === uuid_user.id )
{
email_confirmation_required = 0;
}
// if an extension requires email confirmation, set it to required
if ( event.requires_email_confirmation ) {
email_confirmation_required = 1;
}
// -----------------------------------
// Get referral user
// -----------------------------------
let referred_by_user = undefined;
if ( req.body.referral_code ) {
referred_by_user = await get_user({ referral_code: req.body.referral_code });
if ( ! referred_by_user ) {
return res.status(400).send('Referral code not found');
}
}
// -----------------------------------
// New User
// -----------------------------------
const user_uuid = uuidv4();
const email_confirm_token = uuidv4();
let insert_res;
let email_confirm_code = Math.floor(100000 + Math.random() * 900000);
const audit_metadata = {
ip: req.connection.remoteAddress,
ip_fwd: req.headers['x-forwarded-for'],
user_agent: req.headers['user-agent'],
origin: req.headers['origin'],
server: config.server_id,
};
if ( pseudo_user === undefined ) {
insert_res = await db.write(
`INSERT INTO user
(
username, email, clean_email, password, uuid, referrer,
email_confirm_code, email_confirm_token, free_storage,
referred_by, audit_metadata, signup_ip, signup_ip_forwarded,
signup_user_agent, signup_origin, signup_server, requires_email_confirmation
)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
// username
req.body.username,
// email
req.body.is_temp ? null : req.body.email,
// normalized email
req.body.is_temp ? null : clean_email,
// password
req.body.is_temp ? null : await bcrypt.hash(req.body.password, 8),
// uuid
user_uuid,
// referrer
req.body.referrer ?? null,
// email_confirm_code
`${ email_confirm_code}`,
// email_confirm_token
email_confirm_token,
// free_storage
config.storage_capacity,
// referred_by
referred_by_user ? referred_by_user.id : null,
// audit_metadata
JSON.stringify(audit_metadata),
// signup_ip
req.connection.remoteAddress ?? null,
// signup_ip_fwd
req.headers['x-forwarded-for'] ?? null,
// signup_user_agent
req.headers['user-agent'] ?? null,
// signup_origin
req.headers['origin'] ?? null,
// signup_server
config.server_id ?? null,
// requires_email_confirmation
email_confirmation_required,
],
);
// record activity
db.write(
'UPDATE `user` SET `last_activity_ts` = now() WHERE id=? LIMIT 1',
[insert_res.insertId],
);
// TODO: cache group id
const svc_group = req.services.get('group');
await svc_group.add_users({
uid: req.body.is_temp ?
config.default_temp_group : config.default_user_group,
users: [req.body.username],
});
// send an event for successful signup
const svc_event = req.services.get('event');
svc_event.emit('puter.signup.success', {
user_id: insert_res.insertId,
user_uuid: user_uuid,
email: req.body.email,
username: req.body.username,
password: req.body.password,
ip: req.headers['x-forwarded-for'] || req.connection.remoteAddress,
});
}
// -----------------------------------
// Pseudo User converting
// -----------------------------------
else {
insert_res = await db.write(
`UPDATE user SET
username = ?, password = ?, uuid = ?, email_confirm_code = ?, email_confirm_token = ?, email_confirmed = ?, requires_email_confirmation = 1,
referred_by = ?
WHERE id = ?`,
[
// username
req.body.username,
// password
await bcrypt.hash(req.body.password, 8),
// uuid
user_uuid,
// email_confirm_code
`${ email_confirm_code}`,
// email_confirm_token
email_confirm_token,
// email_confirmed
!email_confirmation_required,
// id
pseudo_user.id,
// referred_by
referred_by_user ? referred_by_user.id : null,
],
);
// TODO: cache group ids
const svc_group = req.services.get('group');
await svc_group.remove_users({
uid: config.default_temp_group,
users: [req.body.username],
});
await svc_group.add_users({
uid: config.default_user_group,
users: [req.body.username],
});
// record activity
db.write('UPDATE `user` SET `last_activity_ts` = now() WHERE id=? LIMIT 1', [pseudo_user.id]);
invalidate_cached_user_by_id(pseudo_user.id);
}
// user id
// todo if pseudo user, assign directly no need to do another DB lookup
const user_id = (pseudo_user === undefined) ? insert_res.insertId : pseudo_user.id;
const [user] = await db.pread(
'SELECT * FROM `user` WHERE `id` = ? LIMIT 1',
[user_id],
);
// create token for login: session token for cookie, GUI token for client
const { session, token: session_token } = await svc_auth.create_session_token(user, {
req,
});
const gui_token = svc_auth.create_gui_token(user, session);
// jwt.sign({uuid: user_uuid}, config.jwt_secret);
//-------------------------------------------------------------
// email confirmation
//-------------------------------------------------------------
// Email confirmation from signup is sent here
if ( (!req.body.is_temp && email_confirmation_required) || user.requires_email_confirmation ) {
if ( req.body.send_confirmation_code || user.requires_email_confirmation )
{
send_email_verification_code(email_confirm_code, user.email);
}
else
{
send_email_verification_token(user.email_confirm_token, user.email, user.uuid);
}
}
//-------------------------------------------------------------
// referral code
//-------------------------------------------------------------
let referral_code;
if ( pseudo_user === undefined ) {
const svc_referralCode = Context.get('services')
.get('referral-code', { optional: true });
if ( svc_referralCode ) {
referral_code = await svc_referralCode.gen_referral_code(user);
}
}
const svc_user = Context.get('services').get('user');
await svc_user.generate_default_fsentries({ user });
// HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie)
res.cookie(config.cookie_name, session_token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
// add to mailchimp
if ( ! req.body.is_temp ) {
const svc_event = Context.get('services').get('event');
svc_event.emit('user.save_account', { user });
}
// return results
return res.send({
token: gui_token,
user: {
username: user.username,
uuid: user.uuid,
email: user.email,
email_confirmed: user.email_confirmed,
requires_email_confirmation: user.requires_email_confirmation,
is_temp: (user.password === null && user.email === null),
taskbar_items: await get_taskbar_items(user),
referral_code,
},
});
});
================================================
FILE: src/backend/src/routers/signup_create_new_user.js
================================================
/*
* 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 .
*/
import config from '../config.js';
import { DB_WRITE } from '../services/database/consts.js';
import { generate_identifier } from '../util/identifier.js';
import { v4 as uuidv4 } from 'uuid';
/**
* Create a new user for signup. Common behavior shared by POST /signup and OIDC signup.
* Form-signup path is still handled in signup.js; this handles OIDC and will support form signup after refactor.
*
* @param {object} services - Backend services (from req.services)
* @param {object} options - Creation options. For OIDC: { providerId, userinfo }. For form signup: TBD (to be refactored from signup.js).
* @returns {Promise} The created user, or null on failure (e.g. email already registered).
*/
async function signup_create_new_user (services, options) {
const { providerId, userinfo } = options;
if ( !providerId || !userinfo ) {
// Form signup: to be refactored from signup.js; not implemented here yet.
return null;
}
const db = await services.get('database').get(DB_WRITE, 'auth');
const svc_group = services.get('group');
const svc_user = services.get('user');
const svc_oidc = services.get('oidc');
if ( ! svc_oidc ) return null;
const claims = userinfo;
let username = (claims.name || claims.email || '').toString().trim();
if ( username ) {
username = username.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, '');
if ( username.length > 45 ) username = username.slice(0, 45);
}
if ( !username || !/^\w+$/.test(username) ) {
let candidate;
do {
candidate = generate_identifier();
const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]);
if ( ! r ) username = candidate;
} while ( !username );
} else {
const [existing] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [username]);
if ( existing ) {
let suffix = 1;
while ( true ) {
const candidate = `${username}${suffix}`;
const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]);
if ( ! r ) {
username = candidate; break;
}
suffix++;
}
}
}
const email = (claims.email || '').toString().trim() || null;
const clean_email = email ? email.toLowerCase().trim() : null;
if ( clean_email ) {
const [existingEmail] = await db.pread('SELECT 1 FROM user WHERE clean_email = ? LIMIT 1', [clean_email]);
if ( existingEmail ) {
return null; // email already registered; caller should return error
}
}
const user_uuid = uuidv4();
const email_confirm_code = String(Math.floor(100000 + Math.random() * 900000));
const email_confirm_token = uuidv4();
await db.write(`INSERT INTO user (
username, email, clean_email, password, uuid, referrer,
email_confirm_code, email_confirm_token, free_storage,
referred_by, email_confirmed, requires_email_confirmation
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
username,
email,
clean_email,
null,
user_uuid,
null,
email_confirm_code,
email_confirm_token,
config.storage_capacity,
null,
1,
0,
]);
const [inserted] = await db.pread('SELECT id FROM user WHERE uuid = ? LIMIT 1', [user_uuid]);
const user_id = inserted.id;
await svc_oidc.linkProviderToUser(user_id, providerId, claims.sub, null);
await svc_group.add_users({
uid: config.default_user_group,
users: [username],
});
const [user] = await db.pread('SELECT * FROM user WHERE id = ? LIMIT 1', [user_id]);
if ( user && user.metadata && typeof user.metadata === 'string' ) {
user.metadata = JSON.parse(user.metadata);
} else if ( user && !user.metadata ) {
user.metadata = {};
}
await svc_user.generate_default_fsentries({ user });
return user;
}
export default signup_create_new_user;
================================================
FILE: src/backend/src/routers/sites.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth.js');
const config = require('../config');
// -----------------------------------------------------------------------//
// POST /sites
// -----------------------------------------------------------------------//
router.post('/sites', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// modules
const { id2path } = require('../helpers');
let db = require('../db/mysql.js');
let dbrr = db.readReplica ?? db;
const user = req.user;
const sites = [];
let [subdomains] = await dbrr.promise().execute(
'SELECT * FROM subdomains WHERE user_id = ?',
[user.id]);
if ( subdomains.length > 0 ) {
for ( let i = 0; i < subdomains.length; i++ ) {
let site = {};
// address
site.address = `${config.protocol }://${ subdomains[i].subdomain }.` + 'puter.site';
// uuid
site.uuid = subdomains[i].uuid;
// dir
let [dir] = await dbrr.promise().execute(
'SELECT * FROM fsentries WHERE id = ?',
[subdomains[i].root_dir_id]);
if ( dir.length > 0 ) {
site.has_dir = true;
site.dir_uid = dir[0].uuid;
site.dir_name = dir[0].name;
site.dir_path = await id2path(dir[0].id);
} else {
site.has_dir = false;
}
sites.push(site);
}
}
res.send(sites);
});
module.exports = router;
================================================
FILE: src/backend/src/routers/suggest_apps.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
const auth = require('../middleware/auth.js');
const config = require('../config');
const { Context } = require('../util/context.js');
const { NodeInternalIDSelector } = require('../filesystem/node/selectors.js');
const { convert_path_to_fsentry, uuid2fsentry, suggestedAppForFsEntry } = require('../helpers');
// -----------------------------------------------------------------------//
// POST /suggest_apps
// -----------------------------------------------------------------------//
router.post('/suggest_apps', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// validation
if ( req.body.uid === undefined && req.body.path === undefined )
{
return res.status(400).send({ message: '`uid` or `path` required' });
}
let fsentry;
// by uid
if ( req.body.uid )
{
fsentry = await uuid2fsentry(req.body.uid);
}
// by path
else {
fsentry = await convert_path_to_fsentry(req.body.path);
if ( fsentry === false )
{
return res.status(400).send('Path not found.');
}
}
const services = Context.get('services');
const fs = services.get('filesystem');
const node = await fs.node(new NodeInternalIDSelector('mysql', fsentry.id, {
source: 'suggest_apps',
}));
// check permission
const actor = req.actor ?? Context.get('actor');
if ( ! actor ) {
return res.status(500).send('failed to get Actor object');
}
const svc_acl = services.get('acl');
if ( ! await svc_acl.check(actor, node, 'read') ) {
(await svc_acl.get_safe_acl_error(actor, node, 'read'))
.write(res);
return;
}
// get suggestions
try {
return res.send(await suggestedAppForFsEntry(fsentry));
}
catch (e) {
return res.status(400).send(e);
}
});
module.exports = router;
================================================
FILE: src/backend/src/routers/test.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
// -----------------------------------------------------------------------//
// GET /test
// -----------------------------------------------------------------------//
router.get('/test', async (req, res, next) => {
res.send('It\'s working!');
});
module.exports = router;
================================================
FILE: src/backend/src/routers/update-taskbar-items.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const config = require('../config.js');
const { invalidate_cached_user } = require('../helpers');
const router = new express.Router();
const auth = require('../middleware/auth.js');
const { DB_WRITE } = require('../services/database/consts.js');
// -----------------------------------------------------------------------//
// POST /update-taskbar-items
// -----------------------------------------------------------------------//
router.post('/update-taskbar-items', auth, express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// modules
const db = req.services.get('database').get(DB_WRITE, 'ui');
// Check if req.body.items is set
if ( ! req.body.items )
{
return res.status(400).send({ code: 'invalid_request', message: 'items is required.' });
}
// Check if req.body.items is an array
else if ( ! Array.isArray(req.body.items) )
{
return res.status(400).send({ code: 'invalid_request', message: 'items must be an array.' });
}
// insert into DB
await db.write(
'UPDATE user SET taskbar_items = ? WHERE user.id = ?',
[
req.body.items ?? null,
req.user.id,
],
);
invalidate_cached_user(req.user);
// send results to client
return res.send({});
});
module.exports = router;
================================================
FILE: src/backend/src/routers/user-protected/change-email.js
================================================
/*
* 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 .
*/
const APIError = require('../../api/APIError');
const { DB_WRITE } = require('../../services/database/consts');
const jwt = require('jsonwebtoken');
const validator = require('validator');
const crypto = require('crypto');
const config = require('../../config');
const { Context } = require('../../util/context');
const { v4: uuidv4 } = require('uuid');
const { invalidate_cached_user_by_id } = require('../../helpers');
module.exports = {
route: '/change-email',
methods: ['POST'],
handler: async (req, res) => {
const user = req.user;
const new_email = req.body.new_email;
// TODO: DRY: signup.js
// validation
if ( ! new_email ) {
throw APIError.create('field_missing', null, { key: 'new_email' });
}
if ( typeof new_email !== 'string' ) {
throw APIError.create('field_invalid', null, {
key: 'new_email', expected: 'a valid email address' });
}
if ( ! validator.isEmail(new_email) ) {
throw APIError.create('field_invalid', null, {
key: 'new_email', expected: 'a valid email address' });
}
const svc_cleanEmail = req.services.get('clean-email');
const clean_email = svc_cleanEmail.clean(new_email);
if ( ! await svc_cleanEmail.validate(clean_email) ) {
throw APIError.create('email_not_allowed', undefined, {
email: clean_email,
});
}
// check if email is already in use
const db = req.services.get('database').get(DB_WRITE, 'auth');
const rows = await db.read(
'SELECT COUNT(*) AS `count` FROM `user` WHERE (`email` = ? OR `clean_email` = ?) AND `email_confirmed` = 1',
[new_email, clean_email],
);
// TODO: DRY: signup.js, save_account.js
if ( rows[0].count > 0 ) {
throw APIError.create('email_already_in_use', null, { email: new_email });
}
// If user does not have a confirmed email, then update `email` directly
// and send a new confirmation email for their account instead.
if ( ! user.email_confirmed ) {
const email_confirm_token = uuidv4();
await db.write(
'UPDATE `user` SET `email` = ?, `email_confirm_token` = ? WHERE `id` = ?',
[new_email, email_confirm_token, user.id],
);
invalidate_cached_user_by_id(user.id);
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: new_email }, 'email_verification_link', { link });
res.send({ success: true });
return;
}
// generate confirmation token
const token = crypto.randomBytes(4).toString('hex');
const jwt_token = jwt.sign({
user_id: user.id,
token,
}, config.jwt_secret, { expiresIn: '24h' });
// send confirmation email
const svc_email = req.services.get('email');
await svc_email.send_email({ email: new_email }, 'email_change_request', {
confirm_url: `${config.origin}/change_email/confirm?token=${jwt_token}`,
username: user.username,
});
const old_email = user.email;
// TODO: NotificationService
await svc_email.send_email({ email: old_email }, 'email_change_notification', {
new_email: new_email,
});
// update user
await db.write(
'UPDATE `user` SET `unconfirmed_change_email` = ?, `change_email_confirm_token` = ? WHERE `id` = ?',
[new_email, token, user.id],
);
invalidate_cached_user_by_id(user.id);
// Update email change audit table
await db.write(
'INSERT INTO `user_update_audit` ' +
'(`user_id`, `user_id_keep`, `old_email`, `new_email`, `reason`) ' +
'VALUES (?, ?, ?, ?, ?)',
[
req.user.id, req.user.id,
old_email, new_email,
'change_username',
],
);
res.send({ success: true });
},
};
================================================
FILE: src/backend/src/routers/user-protected/change-password.js
================================================
/*
* 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 .
*/
// TODO: DRY: This is the same function used by UIWindowChangePassword!
const { invalidate_cached_user } = require('../../helpers');
const { DB_WRITE } = require('../../services/database/consts');
// duplicate definition is in src/helpers.js (puter GUI)
const check_password_strength = (password) => {
// Define criteria for password strength
const criteria = {
minLength: 8,
hasUpperCase: /[A-Z]/.test(password),
hasLowerCase: /[a-z]/.test(password),
hasNumber: /\d/.test(password),
hasSpecialChar: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password),
};
let overallPass = true;
// Initialize report object
let criteria_report = {
minLength: {
message: `Password must be at least ${criteria.minLength} characters long`,
pass: password.length >= criteria.minLength,
},
hasUpperCase: {
message: 'Password must contain at least one uppercase letter',
pass: criteria.hasUpperCase,
},
hasLowerCase: {
message: 'Password must contain at least one lowercase letter',
pass: criteria.hasLowerCase,
},
hasNumber: {
message: 'Password must contain at least one number',
pass: criteria.hasNumber,
},
hasSpecialChar: {
message: 'Password must contain at least one special character',
pass: criteria.hasSpecialChar,
},
};
// Check overall pass status and add messages
for ( let criterion in criteria ) {
if ( ! criteria_report[criterion].pass ) {
overallPass = false;
break;
}
}
return {
overallPass: overallPass,
report: criteria_report,
};
};
module.exports = {
route: '/change-password',
methods: ['POST'],
handler: async (req, res) => {
// Validate new password
const { new_pass } = req.body;
const { overallPass: strong } = check_password_strength(new_pass);
if ( ! strong ) {
req.status(400).send('Password does not meet requirements.');
}
// Update user
// TODO: DI for endpoint definitions like this one
const bcrypt = require('bcrypt');
const db = req.services.get('database').get(DB_WRITE, 'auth');
await db.write(
'UPDATE user SET password=?, `pass_recovery_token` = NULL, `change_email_confirm_token` = NULL WHERE `id` = ?',
[await bcrypt.hash(req.body.new_pass, 8), req.user.id],
);
invalidate_cached_user(req.user);
// Notify user about password change
// TODO: audit log for user in security tab
const svc_email = req.services.get('email');
svc_email.send_email({ email: req.user.email }, 'password_change_notification');
// Kick out all other sessions
const svc_auth = req.services.get('auth');
const sessions = await svc_auth.list_sessions(req.actor);
for ( const session of sessions ) {
if ( session.current ) continue;
await svc_auth.revoke_session(req.actor, session.uuid);
}
return res.send('Password successfully updated.');
},
};
================================================
FILE: src/backend/src/routers/user-protected/change-username.js
================================================
/*
* 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 .
*/
const config = require('../../config');
const APIError = require('../../api/APIError.js');
const { DB_WRITE } = require('../../services/database/consts');
const { username_exists, change_username } = require('../../helpers');
const { Context } = require('../../util/context');
module.exports = {
route: '/change-username',
methods: ['POST'],
handler: async (req, res, _next) => {
const user = req.user;
const new_username = req.body.new_username;
if ( ! new_username ) {
throw APIError.create('field_missing', null, { key: 'new_username' });
}
if ( typeof new_username !== 'string' ) {
throw APIError.create('field_invalid', null, { key: 'new_username', expected: 'a string' });
}
if ( ! new_username.match(config.username_regex) ) {
throw APIError.create('field_invalid', null, { key: 'new_username', expected: 'letters, numbers, underscore (_)' });
}
if ( new_username.length > config.username_max_length ) {
throw APIError.create('field_too_long', null, { key: 'new_username', max_length: config.username_max_length });
}
if ( await username_exists(new_username) ) {
throw APIError.create('username_already_in_use', null, { username: new_username });
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('/user-protected/change-username') ) {
return res.status(429).send('Too many requests.');
}
const db = Context.get('services').get('database').get(DB_WRITE, 'auth');
const rows = await db.read(
'SELECT COUNT(*) AS `count` FROM `user_update_audit` ' +
`WHERE \`user_id\`=? AND \`reason\`=? AND ${
db.case({
mysql: '`created_at` > DATE_SUB(NOW(), INTERVAL 1 MONTH)',
sqlite: "`created_at` > datetime('now', '-1 month')",
})}`,
[user.id, 'change_username'],
);
if ( rows[0].count >= (config.max_username_changes ?? 2) ) {
throw APIError.create('too_many_username_changes');
}
await db.write(
'INSERT INTO `user_update_audit` ' +
'(`user_id`, `user_id_keep`, `old_username`, `new_username`, `reason`) ' +
'VALUES (?, ?, ?, ?, ?)',
[user.id, user.id, user.username, new_username, 'change_username'],
);
await change_username(user.id, new_username);
res.json({});
},
};
================================================
FILE: src/backend/src/routers/user-protected/delete-own-user.js
================================================
/*
* Copyright (C) 2026-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 .
*/
const config = require('../../config');
const { deleteUser, invalidate_cached_user } = require('../../helpers');
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
module.exports = {
route: '/delete-own-user',
methods: ['POST'],
handler: async (req, res) => {
res.clearCookie(config.cookie_name);
res.clearCookie(REVALIDATION_COOKIE_NAME);
await deleteUser(req.user.id);
invalidate_cached_user(req.user);
return res.send({ success: true });
},
};
================================================
FILE: src/backend/src/routers/user-protected/disable-2fa.js
================================================
/*
* 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 .
*/
const { DB_WRITE } = require('../../services/database/consts');
const { invalidate_cached_user_by_id } = require('../../helpers');
module.exports = {
route: '/disable-2fa',
methods: ['POST'],
handler: async (req, res) => {
const db = req.services.get('database').get(DB_WRITE, '2fa.disable');
await db.write(
'UPDATE user SET otp_enabled = 0, otp_recovery_codes = NULL, otp_secret = NULL WHERE uuid = ?',
[req.user.uuid],
);
// update cached user
req.user.otp_enabled = 0;
invalidate_cached_user_by_id(req.user.id);
const svc_email = req.services.get('email');
await svc_email.send_email({ email: req.user.email }, 'disabled_2fa', {
username: req.user.username,
});
res.send({ success: true });
},
};
================================================
FILE: src/backend/src/routers/verify-pass-recovery-token.js
================================================
/*
* 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 .
*/
'use strict';
const express = require('express');
const router = new express.Router();
const config = require('../config');
const { get_user } = require('../helpers');
const jwt = require('jsonwebtoken');
// Ensure we don't expose branches with differing messages.
const SAFE_NEGATIVE_RESPONSE = 'This password recovery token is no longer valid.';
// -----------------------------------------------------------------------//
// POST /verify-pass-recovery-token
// -----------------------------------------------------------------------//
router.post('/verify-pass-recovery-token', express.json(), async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '' )
{
next();
}
if ( ! req.body.token ) {
return res.status(401).send('token is required');
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('verify-pass-recovery-token') ) {
return res.status(429).send('Too many requests.');
}
const { exp, user_uid, email } = jwt.verify(req.body.token, config.jwt_secret);
const user = await get_user({ uuid: user_uid, force: true });
if ( user.email !== email ) {
return res.status(400).send(SAFE_NEGATIVE_RESPONSE);
}
const current_time = Math.floor(Date.now() / 1000);
const time_remaining = exp - current_time;
return res.status(200).send({ time_remaining });
});
module.exports = router;
================================================
FILE: src/backend/src/routers/version.js
================================================
/*
* 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 .
*/
const eggspress = require('../api/eggspress');
module.exports = eggspress(['/version'], {
allowedMethods: ['GET'],
subdomain: 'api',
json: true,
}, async (req, res, next) => {
const svc_puterVersion = req.services.get('puter-version');
const response = svc_puterVersion.get_version();
// Add user-friendly version information
{
response.version_text = response.version;
const components = response.version.split('-');
if ( components.length > 1 ) {
response.release_type = components[1];
if ( components[1] === 'rc' ) {
response.version_text =
`${components[0]} (Release Candidate ${components[2]})`;
}
else if ( components[1] === 'dev' ) {
response.version_text =
`${components[0]} (Development Build)`;
}
else if ( components[1] === 'beta' ) {
response.version_text =
`${components[0]} (Beta Release)`;
}
else if ( ! isNaN(components[1]) ) {
response.version_text = `${components[0]} (Build ${components[1]})`;
response.sub_version = components[1];
response.hash = components[2];
response.release_type = 'build';
}
if ( isNaN(components[1]) && components.length > 2 ) {
response.sub_version = components[2];
}
}
}
res.send(response);
});
================================================
FILE: src/backend/src/routers/writeFile/copy.js
================================================
/*
* 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 .
*/
const { HLCopy } = require('../../filesystem/hl_operations/hl_copy');
module.exports = async function writeFile_handle_copy ({
api,
req, res, actor, node,
}) {
// check if destination_write_url provided
// check if destination_write_url is valid
const dest_node = await api.get_dest_node();
if ( ! dest_node ) return;
const overwrite = req.body.overwrite ?? false;
const change_name = req.body.auto_rename ?? false;
const opts = {
source: node,
destination_or_parent: dest_node,
dedupe_name: change_name,
overwrite,
user: actor.type.user,
};
const hl_copy = new HLCopy();
const r = await hl_copy.run({
...opts,
actor,
});
return res.send([r]);
};
================================================
FILE: src/backend/src/routers/writeFile/delete.js
================================================
/*
* 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 .
*/
const { HLRemove } = require('../../filesystem/hl_operations/hl_remove');
module.exports = async function writeFile_handle_delete ({
req, res, actor, node,
}) {
// Delete
const hl_remove = new HLRemove();
await hl_remove.run({
target: node,
user: actor.type.user,
actor,
});
// Send success msg
return res.send();
};
================================================
FILE: src/backend/src/routers/writeFile/mkdir.js
================================================
/*
* 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 .
*/
const { HLMkdir } = require('../../filesystem/hl_operations/hl_mkdir');
const { NodeUIDSelector } = require('../../filesystem/node/selectors');
const { sign_file } = require('../../helpers');
module.exports = async function writeFile_handle_mkdir ({
req, res, actor, node,
}) {
if ( ! req.body.name ) {
return res.status(400).send({
error: {
message: 'Name is required.',
},
});
}
const hl_mkdir = new HLMkdir();
const r = await hl_mkdir.run({
parent: node,
path: req.body.name,
overwrite: false,
dedupe_name: req.body.dedupe_name ?? false,
user: actor.type.user,
actor,
});
const svc_fs = req.services.get('filesystem');
const newdir_node = await svc_fs.node(new NodeUIDSelector(r.uid));
return res.send(await sign_file(await newdir_node.get('entry'), 'write'));
};
================================================
FILE: src/backend/src/routers/writeFile/move.js
================================================
/*
* 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 .
*/
const { HLMove } = require('../../filesystem/hl_operations/hl_move');
module.exports = async function writeFile_handle_move ({
api,
req, res, actor, node,
}) {
// check if destination_write_url provided
if ( ! req.body.destination_write_url ) {
return res.status(400).send({
error: {
message: 'No destination specified.',
},
});
}
const dest_node = await api.get_dest_node();
if ( ! dest_node ) return;
const hl_move = new HLMove();
const opts = {
user: actor.type.user,
source: node,
destination_or_parent: dest_node,
overwrite: req.body.overwrite ?? false,
new_name: req.body.new_name,
new_metadata: req.body.new_metadata,
create_missing_parents: req.body.create_missing_parents,
};
const r = await hl_move.run({
...opts,
actor,
});
return res.send({
...r.moved,
old_path: r.old_path,
new_path: r.moved.path,
});
};
================================================
FILE: src/backend/src/routers/writeFile/rename.js
================================================
/*
* 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 .
*/
const mime = require('mime-types');
const { validate_fsentry_name } = require('../../helpers');
const { DB_WRITE } = require('../../services/database/consts');
module.exports = async function writeFile_handle_rename ({
req, res, node,
}) {
const new_name = req.body.new_name;
try {
validate_fsentry_name(new_name);
} catch (e) {
return res.status(400).send({
error: {
message: e.message,
},
});
}
if ( await node.get('immutable') ) {
return res.status(400).send({
error: {
message: 'Immutable: cannot rename.',
},
});
}
if ( await node.isUserDirectory() || await node.isRoot ) {
return res.status(403).send({
error: {
message: 'Not allowed to rename this item via writeFile.',
},
});
}
const old_path = await node.get('path');
const db = req.services.get('database').get(DB_WRITE, 'writeFile:rename');
const mysql_id = await node.get('mysql-id');
await db.write('UPDATE fsentries SET name = ? WHERE id = ?',
[new_name, mysql_id]);
const contentType = mime.contentType(req.body.new_name);
const return_obj = {
...await node.getSafeEntry(),
old_path,
type: contentType ? contentType : null,
original_client_socket_id: req.body.original_client_socket_id,
};
return res.send(return_obj);
};
================================================
FILE: src/backend/src/routers/writeFile/trash.js
================================================
/*
* 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 .
*/
const { HLMove } = require('../../filesystem/hl_operations/hl_move');
const { NodePathSelector } = require('../../filesystem/node/selectors');
module.exports = async function writeFile_handle_trash ({
req, res, actor, node,
}) {
// metadata for trashed file
const new_name = await node.get('uid');
const metadata = {
original_name: await node.get('name'),
original_path: await node.get('path'),
trashed_ts: Math.round(Date.now() / 1000),
};
// Get Trash fsentry
const fs = req.services.get('filesystem');
const trash = await fs.node(new NodePathSelector(`/${ actor.type.user.username }/Trash`));
// No Trash?
if ( ! trash ) {
return res.status(400).send({
error: {
message: 'No Trash directory found.',
},
});
}
const hl_move = new HLMove();
await hl_move.run({
source: node,
destination_or_parent: trash,
user: actor.type.user,
actor,
new_name: new_name,
new_metadata: metadata,
});
return res.status(200).send({
message: 'Item trashed',
});
};
================================================
FILE: src/backend/src/routers/writeFile/write.js
================================================
/*
* 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 .
*/
const { TYPE_DIRECTORY } = require('../../filesystem/FSNodeContext');
const { HLWrite } = require('../../filesystem/hl_operations/hl_write');
const { NodePathSelector } = require('../../filesystem/node/selectors');
const _path = require('path');
const { sign_file } = require('../../helpers');
module.exports = async function writeFile_handle_write ({
req, res, actor, node,
}) {
// Check if files were uploaded
if ( ! req.files ) {
return res.status(400).send('No files uploaded');
}
// Get fsentry
let dirname;
try {
dirname = (await node.get('type') !== TYPE_DIRECTORY
? _path.dirname.bind(_path) : a => a)(await node.get('path'));
} catch (e) {
console.log(e);
req.__error_source = e;
return res.status(500).send(e);
}
const svc_fs = req.services.get('filesystem');
const dirNode = await svc_fs.node(new NodePathSelector(dirname));
// Upload files one by one
const returns = [];
for ( const uploaded_file of req.files ) {
try {
const normalized_file = { ...uploaded_file };
if ( normalized_file.mimetype && !normalized_file.type ) {
normalized_file.type = normalized_file.mimetype;
}
if ( normalized_file.buffer ) {
normalized_file.size = normalized_file.buffer.length;
}
const hl_write = new HLWrite();
const ret_obj = await hl_write.run({
destination_or_parent: dirNode,
specified_name: await node.get('type') === TYPE_DIRECTORY
? req.body.name : await node.get('name'),
fallback_name: normalized_file.originalname,
overwrite: true,
user: actor.type.user,
actor,
file: normalized_file,
});
// add signature to object
ret_obj.signature = await sign_file(ret_obj, 'write');
// send results back to app
returns.push(ret_obj);
} catch ( error ) {
req.__error_source = error;
console.log(error);
return res.contentType('application/json').status(500).send(error);
}
}
if ( returns.length === 1 ) {
return res.send(returns[0]);
}
return res.send(returns);
};
================================================
FILE: src/backend/src/routers/writeFile/writeFile_handlers.js
================================================
/*
* 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 .
*/
module.exports = {
move: require('./move'),
copy: require('./copy'),
mkdir: require('./mkdir'),
trash: require('./trash'),
delete: require('./delete'),
rename: require('./rename'),
write: require('./write'),
};
================================================
FILE: src/backend/src/routers/writeFile.js
================================================
/*
* 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 .
*/
'use strict';
const { uuid2fsentry, validate_signature_auth, get_url_from_req, get_user } = require('../helpers');
const eggspress = require('../api/eggspress');
const { Context } = require('../util/context');
const { Actor } = require('../services/auth/Actor');
const FSNodeParam = require('../api/filesystem/FSNodeParam');
// TODO: eggspressify
// -----------------------------------------------------------------------//
// POST /writeFile
// -----------------------------------------------------------------------//
module.exports = eggspress('/writeFile', {
files: ['file'],
allowedMethods: ['POST'],
}, async (req, res, next) => {
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
const log = req.services.get('log-service').create('writeFile');
const errors = req.services.get('error-service').create(log);
// validate URL signature
try {
validate_signature_auth(get_url_from_req(req), 'write');
}
catch (e) {
return res.status(403).send(e);
}
// Get fsentry
// todo this is done again in the following section, super inefficient
let requested_item = await uuid2fsentry(req.query.uid);
if ( ! requested_item ) {
return res.status(404).send({ error: 'Item not found' });
}
// check if requested_item owner is suspended
const owner_user = await require('../helpers').get_user({ id: requested_item.user_id });
if ( ! owner_user ) {
errors.report('writeFile_no_owner', {
message: `User not found: ${requested_item.user_id}`,
trace: true,
alarm: true,
extra: {
requested_item,
body: req.body,
query: req.query,
},
});
return res.status(500).send({ error: 'User not found' });
}
if ( owner_user.suspended )
{
return res.status(401).send({ error: 'Account suspended' });
}
const writeFile_handler_api = {
async get_dest_node () {
if ( ! req.body.destination_write_url ) {
res.status(400).send({
error: {
message: 'No destination specified.',
},
});
return;
}
try {
validate_signature_auth(req.body.destination_write_url, 'write', {
uid: req.body.destination_uid,
});
} catch (e) {
res.status(403).send(e);
return;
}
try {
return await (new FSNodeParam('dest_path')).consolidate({
req, getParam: () => req.body.dest_path ?? req.body.destination_uid,
});
} catch (e) {
res.status(500).send('Internal Server Error');
}
},
};
const writeFile_handlers = require('./writeFile/writeFile_handlers.js');
let operation = req.query.operation ?? 'write';
// Responding with an error here would typically be better,
// but it would cause a regression for apps.
if ( ! writeFile_handlers.hasOwnProperty(operation) ) {
operation = 'write';
}
console.log(`\x1B[36;1mwriteFile: ${ req.query.operation }\x1B[0m`);
const node = await (new FSNodeParam('uid')).consolidate({
req, getParam: () => req.query.uid,
});
const user = await get_user({ id: await node.get('user_id') });
const actor = Actor.adapt(user);
return await Context.get().sub({
actor: Actor.adapt(user), user,
}).arun(async () => {
return await writeFile_handlers[operation]({
api: writeFile_handler_api,
req,
res,
actor,
node,
});
});
});
================================================
FILE: src/backend/src/server
================================================
================================================
FILE: src/backend/src/services/AWSSecretsPopulator.js
================================================
const { createTransformedValues, DO_NOT_DEFINE } = require('../util/objutil');
const BaseService = require('./BaseService');
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
class AWSSecretsPopulator extends BaseService {
async _run_as_early_as_possible () {
const secret_name = 'puter-secrets';
const client = new SecretsManagerClient({
region: 'us-west-2',
});
let response;
try {
response = await client.send(new GetSecretValueCommand({
SecretId: secret_name,
VersionStage: 'AWSCURRENT', // VersionStage defaults to AWSCURRENT if unspecified
}));
const secretOverlay = (JSON.parse(response.SecretString));
const config = this.global_config;
config.__set_config_object__(createTransformedValues(this.global_config, {
mutateValue: (value, { state }) => {
const path = state.keys.join('.'); // or jq
if ( value === '$__AWS_SECRET__' ) {
if ( ! secretOverlay[path] ) {
throw new Error('Value wants an AWS Secrets key value, but no such value is in AWS secrets!');
}
return secretOverlay[path];
} else {
return DO_NOT_DEFINE;
}
},
doNotProcessArrays: true,
}));
} catch ( error ) {
// Just dont do anything
}
}
}
module.exports = {
AWSSecretsPopulator,
};
================================================
FILE: src/backend/src/services/AnomalyService.js
================================================
/*
* 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 .
*/
const BaseService = require('./BaseService');
// Symbol used to indicate a denial of service instruction in anomaly handling.
const DENY_SERVICE_INSTRUCTION = Symbol('DENY_SERVICE_INSTRUCTION');
/**
* @class AnomalyService
* @extends BaseService
* @description The AnomalyService class is responsible for managing and processing anomaly detection types and configurations.
* It allows the registration of different types with associated handlers, enabling the detection of anomalies based on specified criteria.
*/
class AnomalyService extends BaseService {
/**
* AnomalyService class that extends BaseService and provides methods
* for registering anomaly types and handling incoming data for those anomalies.
*
* The register method allows the registration of different anomaly types
* and their respective configurations, including custom handlers for data
* evaluation. It supports two modes of operation: a direct handler or
* a threshold-based evaluation.
*/
_construct () {
this.types = {};
}
/**
* Registers a new type with the service, including its configuration and handler.
*
* @param {string} type - The name of the type to register.
* @param {Object} config - The configuration object for the type.
* @param {Function} [config.handler] - An optional handler function for the type.
* @param {number} [config.high] - An optional threshold value; triggers the handler if exceeded.
*
* @returns {void}
*/
register (type, config) {
const type_instance = {
config,
};
if ( config.handler ) {
type_instance.handler = config.handler;
} else if ( config.high ) {
type_instance.handler = data => {
if ( data.value > config.high ) {
return new Set([DENY_SERVICE_INSTRUCTION]);
}
};
}
this.types[type] = type_instance;
}
/**
* Creates a note of the specified type with the provided data.
* See `groups_user_hour` in GroupService for an example.
*
* @param {*} id - The identifier of the type to create a note for.
* @param {*} data - The data to process with the type's handler.
* @returns
*/
async note (id, data) {
const type = this.types[id];
if ( ! type ) return;
return type.handler(data);
}
}
module.exports = {
AnomalyService,
DENY_SERVICE_INSTRUCTION,
};
================================================
FILE: src/backend/src/services/AnomalyService.test.ts
================================================
import { describe, expect, it, vi } from 'vitest';
import { createTestKernel } from '../../tools/test.mjs';
import { AnomalyService, DENY_SERVICE_INSTRUCTION } from './AnomalyService';
describe('AnomalyService', async () => {
const testKernel = await createTestKernel({
serviceMap: {
'anomaly': AnomalyService,
},
initLevelString: 'init',
});
const anomalyService = testKernel.services!.get('anomaly') as any;
it('should be instantiated', () => {
expect(anomalyService).toBeInstanceOf(AnomalyService);
});
it('should have types object', () => {
expect(anomalyService.types).toBeDefined();
expect(typeof anomalyService.types).toBe('object');
});
it('should register a type with handler', () => {
const handler = vi.fn();
anomalyService.register('test-type', { handler });
expect(anomalyService.types['test-type']).toBeDefined();
expect(anomalyService.types['test-type'].handler).toBe(handler);
});
it('should register a type with threshold', () => {
anomalyService.register('threshold-type', { high: 100 });
expect(anomalyService.types['threshold-type']).toBeDefined();
expect(anomalyService.types['threshold-type'].handler).toBeDefined();
expect(typeof anomalyService.types['threshold-type'].handler).toBe('function');
});
it('should call handler when noting anomaly', async () => {
const handler = vi.fn().mockReturnValue('result');
anomalyService.register('callable-type', { handler });
const data = { test: 'data' };
const result = await anomalyService.note('callable-type', data);
expect(handler).toHaveBeenCalledWith(data);
expect(result).toBe('result');
});
it('should return undefined for unregistered type', async () => {
const result = await anomalyService.note('non-existent-type', {});
expect(result).toBeUndefined();
});
it('should trigger threshold handler when value exceeds high', async () => {
anomalyService.register('high-threshold', { high: 50 });
const result = await anomalyService.note('high-threshold', { value: 75 });
expect(result).toBeDefined();
expect(result).toBeInstanceOf(Set);
expect(result.has(DENY_SERVICE_INSTRUCTION)).toBe(true);
});
it('should not trigger threshold handler when value is below high', async () => {
anomalyService.register('low-threshold', { high: 100 });
const result = await anomalyService.note('low-threshold', { value: 50 });
expect(result).toBeUndefined();
});
it('should handle multiple type registrations', () => {
anomalyService.register('type1', { handler: () => {} });
anomalyService.register('type2', { high: 100 });
anomalyService.register('type3', { handler: () => {} });
expect(anomalyService.types['type1']).toBeDefined();
expect(anomalyService.types['type2']).toBeDefined();
expect(anomalyService.types['type3']).toBeDefined();
});
it('should store config in type instance', () => {
const config = { high: 200, custom: 'value' };
anomalyService.register('config-type', config);
expect(anomalyService.types['config-type'].config).toBe(config);
});
it('should handle exact threshold value', async () => {
anomalyService.register('exact-threshold', { high: 100 });
const result = await anomalyService.note('exact-threshold', { value: 100 });
// Threshold uses > not >=, so equal should not trigger
expect(result).toBeUndefined();
});
it('should handle value just over threshold', async () => {
anomalyService.register('just-over', { high: 100 });
const result = await anomalyService.note('just-over', { value: 100.1 });
expect(result).toBeDefined();
expect(result).toBeInstanceOf(Set);
expect(result.has(DENY_SERVICE_INSTRUCTION)).toBe(true);
});
it('should allow custom handler to return any value', async () => {
const customResult = { custom: 'result', data: [1, 2, 3] };
anomalyService.register('custom-return', {
handler: () => customResult
});
const result = await anomalyService.note('custom-return', {});
expect(result).toBe(customResult);
});
});
describe('DENY_SERVICE_INSTRUCTION', () => {
it('should be a symbol', () => {
expect(typeof DENY_SERVICE_INSTRUCTION).toBe('symbol');
});
it('should be unique', () => {
const anotherSymbol = Symbol('DENY_SERVICE_INSTRUCTION');
expect(DENY_SERVICE_INSTRUCTION).not.toBe(anotherSymbol);
});
});
================================================
FILE: src/backend/src/services/BaseService.d.ts
================================================
import type { ErrorService } from '@heyputer/backend/src/modules/core/ErrorService';
import type { DriverService } from '@heyputer/backend/src/services/drivers/DriverService';
import type { DynamoKVStore } from '@heyputer/backend/src/services/DynamoKVStore/DynamoKVStore';
import type { DDBClient } from '../clients/dynamodb/DDBClient';
import type { ServerHealthService } from '../modules/core/ServerHealthService/ServerHealthService';
import type { WebServerService } from '../modules/web/WebServerService';
import type { GroupService } from './auth/GroupService';
import type { SignupService } from './auth/SignupService';
import type { CleanEmailService } from './CleanEmailService';
import type { SqliteDatabaseAccessService } from './database/SqliteDatabaseAccessService';
import type { IDynamoKVStoreWrapper } from './DynamoKVStore/DynamoKVStoreWrapper';
import type { Emailservice } from './EmailService';
import type { EntityStoreService } from './EntityStoreService';
import type { EventService } from './EventService';
import type { FeatureFlagService } from './FeatureFlagService';
import type { GetUserService } from './GetUserService';
import type { MeteringService } from './MeteringService/MeteringService';
import type { MeteringServiceWrapper } from './MeteringService/MeteringServiceWrapper.mjs';
import type { SUService } from './SUService';
import type { UserService } from './UserService';
import { TokenService } from './auth/TokenService';
export interface ServicesMap {
su: SUService;
user: UserService;
'get-user': GetUserService;
'web-server': WebServerService;
email: Emailservice;
'es:app': EntityStoreService;
meteringService: MeteringService & MeteringServiceWrapper;
'puter-kvstore': DynamoKVStore & IDynamoKVStoreWrapper;
database: SqliteDatabaseAccessService;
'server-health': ServerHealthService;
su: SUService;
dynamo: DDBClient;
user: UserService;
event: EventService;
signup: SignupService;
group: GroupService;
'feature-flag': FeatureFlagService;
'clean-email': CleanEmailService;
'error-service': ErrorService;
driver: DriverService;
'token': TokenService
}
export interface ServiceResources {
services: {
get(
name: T
): T extends `${infer R extends keyof ServicesMap}`
? ServicesMap[R]
: unknown;
};
config: Record & { services?: Record; server_id?: string };
name?: string;
args?: any;
context: { get (key: string): any };
}
export type EventHandler = (id: string, ...args: any[]) => any;
export interface Logger {
debug: (...args: any[]) => any;
info: (...args: any[]) => any;
[key: string]: any;
}
export class BaseService {
constructor (service_resources: ServiceResources, ...a: any[]);
args: any;
service_name: string;
services: ServiceResources['services'];
config: Record;
global_config: ServiceResources['config'];
context: ServiceResources['context'];
log: Logger;
errors: any;
as (interfaceName: string): Record;
run_as_early_as_possible (): Promise;
construct (): Promise;
init (): Promise;
__on (id: string, args: any[]): Promise;
protected __get_event_handler (id: string): EventHandler;
protected _run_as_early_as_possible? (args?: any): any;
protected _construct? (args?: any): any;
protected _init? (args?: any): any;
protected _get_merged_static_object? (key: string): Record;
static LOG_DEBUG?: boolean;
static CONCERN?: string;
}
export default BaseService;
================================================
FILE: src/backend/src/services/BaseService.js
================================================
/*
* 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 .
*/
const { concepts } = require('@heyputer/putility');
// This is a no-op function that AI is incapable of writing a comment for.
// That said, I suppose it didn't need one anyway.
const NOOP = async () => {
};
/**
* @class BaseService
* @extends concepts.Service
* @description
* BaseService is the foundational class for all services in the Puter backend.
* It provides lifecycle methods like `construct` and `init` that are invoked during
* different phases of the boot sequence. This class ensures that services can be
* instantiated, initialized, and activated in a coordinated manner through
* events emitted by the Kernel. It also manages common service resources like
* logging and error handling, and supports legacy services by allowing
* instantiation after initialization but before consolidation.
*/
class BaseService extends concepts.Service {
constructor (service_resources, ...a) {
const { services, config, name, args, context } = service_resources;
super(service_resources, ...a);
this.args = args;
this.service_name = name || this.constructor.name;
this.services = services;
let configOverride = undefined;
Object.defineProperty(this, 'config', {
get: () => configOverride ?? config.services?.[name] ?? {},
set: why => {
// TODO: uncomment and fix these in legacy services
// (not very important; low priority)
// console.warn('replacing config like this is probably a bad idea');
configOverride = why;
},
});
this.global_config = config;
this.context = context;
if ( this.global_config.server_id === '' ) {
this.global_config.server_id = 'local';
}
}
async run_as_early_as_possible () {
await (this._run_as_early_as_possible || NOOP).call(this, this.args);
}
/**
* Creates the service's data structures and initial values.
* This method sets up logging and error handling, and calls a custom `_construct` method if defined.
*
* @returns {Promise} A promise that resolves when construction is complete.
*/
async construct () {
const useapi = this.context.get('useapi');
const use = this._get_merged_static_object('USE');
for ( const [key, value] of Object.entries(use) ) {
this[key] = useapi.use(value);
}
await (this._construct || NOOP).call(this, this.args);
}
/**
* Performs the initialization phase of the service lifecycle.
* This method sets up logging and error handling for the service,
* then calls the service-specific initialization logic if defined.
*
* @async
* @memberof BaseService
* @instance
* @returns {Promise} A promise that resolves when initialization is complete.
*/
async init () {
const services = this.services;
const log_fields = {};
if ( this.constructor.CONCERN ) {
log_fields.concern = this.constructor.CONCERN;
}
this.log = services.get('log-service').create(this.service_name, log_fields);
// INFO logs are treated as DEBUG logs instead if...
if (
// The configuration file explicitly says to do so
this.config.log_debug ||
// The class has `static LOG_DEBUG = true`; AND,
// the configuration file does NOT explicitly say NOT to do this
(!this.config.log_info && this.constructor.LOG_DEBUG)
) {
this.log.info = this.log.debug;
}
this.errors = services.get('error-service').create(this.log);
await (this._init || NOOP).call(this, this.args);
}
/**
* Handles an event by retrieving the appropriate event handler
* and executing it with the provided arguments.
*
* @param {string} id - The identifier of the event to handle.
* @param {Array} args - The arguments to pass to the event handler.
* @returns {Promise} The result of the event handler execution.
*/
async __on (id, args) {
const handler = this.__get_event_handler(id);
return await handler(id, ...args);
}
__get_event_handler (id) {
return this[`__on_${id}`]?.bind?.(this)
|| this.constructor[`__on_${id}`]?.bind?.(this.constructor)
|| NOOP;
}
}
module.exports = BaseService;
module.exports.BaseService = BaseService;
================================================
FILE: src/backend/src/services/BootScriptService.js
================================================
/*
* 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 .
*/
const { Context } = require('../util/context');
const BaseService = require('./BaseService');
/**
* @class BootScriptService
* @extends BaseService
* @description The BootScriptService class extends BaseService and is responsible for
* managing and executing boot scripts. It provides methods to handle boot scripts when
* the system is ready and to run individual script commands.
*/
class BootScriptService extends BaseService {
static MODULES = {
fs: require('fs'),
};
/**
* Loads and executes a boot script if specified in the arguments.
*
* This method reads the provided boot script file, parses it, and runs the script using the `run_script` method.
* If no boot script is specified in the arguments, the method returns immediately.
*
* @async
* @function
* @returns {Promise}
*/
async '__on_boot.ready' () {
const args = Context.get('args');
if ( ! args['boot-script'] ) return;
const script_name = args['boot-script'];
const require = this.require;
const fs = require('fs');
const boot_json_raw = fs.readFileSync(script_name, 'utf8');
const boot_json = JSON.parse(boot_json_raw);
await this.run_script(boot_json);
}
/**
* Executes a series of commands defined in a JSON boot script.
*
* This method processes each command in the boot_json array.
* If the command is recognized within the predefined scope, it will be executed.
* If not, an error is thrown.
*
* @param {Array} boot_json - An array of commands to execute.
* @throws {Error} Thrown if an unknown command is encountered.
*/
async run_script (boot_json) {
const scope = {
runner: 'boot-script',
'end-puter-process': ({ args }) => {
const svc_shutdown = this.services.get('shutdown');
svc_shutdown.shutdown(args[0]);
},
};
for ( const statement of boot_json ) {
const [cmd, ...args] = statement;
if ( ! scope[cmd] ) {
throw new Error(`Unknown command: ${cmd}`);
}
await scope[cmd]({ scope, args });
}
}
}
module.exports = {
BootScriptService,
};
================================================
FILE: src/backend/src/services/ChatAPIService.js
================================================
/*
* 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 .
*/
const { Endpoint } = require('../util/expressutil');
const BaseService = require('./BaseService');
const APIError = require('../api/APIError');
/**
* @class ChatAPIService
* @extends BaseService
* @description Service class that handles public (unauthenticated) API endpoints for AI chat functionality.
* This service provides endpoints for retrieving available AI chat models without requiring authentication.
*/
class ChatAPIService extends BaseService {
static MODULES = {
express: require('express'),
Endpoint: Endpoint,
};
/**
* Installs routes for chat API endpoints into the Express app
* @param {Object} _ Unused parameter
* @param {Object} options Installation options
* @param {Express} options.app Express application instance to install routes on
* @returns {Promise}
*/
async '__on_install.routes' (_, { app }) {
// Create a router for chat API endpoints
const router = (() => {
const require = this.require;
const express = require('express');
return express.Router();
})();
// Register the router with the Express app
app.use('/puterai', router);
// Install endpoints
this.install_chat_endpoints_({ router });
}
/**
* Installs chat API endpoints on the provided router
* @param {Object} options Options object
* @param {express.Router} options.router Express router to install endpoints on
* @private
*/
install_chat_endpoints_ ({ router }) {
const Endpoint = this.require('Endpoint');
router.use(require('../routers/puterai/openai/completions'));
router.use(require('../routers/puterai/openai/chat_completions'));
// Endpoint to list available AI chat models
Endpoint({
route: '/chat/models',
methods: ['GET'],
handler: async (req, res) => {
try {
// Use SUService to access AIChatService as system user
const svc_su = this.services.get('su');
const models = await svc_su.sudo(async () => {
const svc_aiChat = this.services.get('ai-chat');
// Return the simple model list which contains basic model information
return svc_aiChat.list();
});
// Return the list of models
res.json({ models: models.filter(e => !['costly', 'fake', 'abuse', 'model-fallback-test-1'].includes(e)) });
} catch ( error ) {
this.log.error('Error fetching models:', error);
throw APIError.create('internal_server_error');
}
},
}).attach(router);
// Endpoint to get detailed information about available AI chat models
Endpoint({
route: '/chat/models/details',
methods: ['GET'],
handler: async (req, res) => {
try {
// Use SUService to access AIChatService as system user
const svc_su = this.services.get('su');
const models = await svc_su.sudo(async () => {
const svc_aiChat = this.services.get('ai-chat');
// Return the detailed model list which includes cost and capability information
return svc_aiChat.models();
});
// Return the detailed list of models
res.json({ models: models.filter((e) => !['costly', 'fake', 'abuse', 'model-fallback-test-1'].includes(e.id)) });
} catch ( error ) {
this.log.error('Error fetching model details:', error);
throw APIError.create('internal_server_error');
}
},
}).attach(router);
Endpoint({
route: '/image/models',
methods: ['GET'],
handler: async (req, res) => {
try {
// Use SUService to access AIImageGenerationService as system user
const svc_su = this.services.get('su');
const models = await svc_su.sudo(async () => {
const svc_imageGen = this.services.get('ai-image');
// Return the simple model list which contains basic model information
return svc_imageGen.list();
});
// Return the list of models
res.json({ models });
} catch ( error ) {
this.log.error('Error fetching image models:', error);
throw APIError.create('internal_server_error');
}
},
}).attach(router);
Endpoint({
route: '/image/models/details',
methods: ['GET'],
handler: async (req, res) => {
try {
// Use SUService to access AIImageGenerationService as system user
const svc_su = this.services.get('su');
const models = await svc_su.sudo(async () => {
const svc_imageGen = this.services.get('ai-image');
// Return the detailed model list which includes cost and capability information
return svc_imageGen.models();
});
// Return the detailed list of models
res.json({ models });
} catch ( error ) {
this.log.error('Error fetching image model details:', error);
throw APIError.create('internal_server_error');
}
},
}).attach(router);
Endpoint({
route: '/video/models/details',
methods: ['GET'],
handler: async (req, res) => {
try {
const svc_su = this.services.get('su');
const models = await svc_su.sudo(async () => {
const items = [];
if ( this.services.has('openai-video-generation') ) {
const svc_video = this.services.get('openai-video-generation');
if ( typeof svc_video.models === 'function' ) {
items.push(...await svc_video.models());
}
}
if ( this.services.has('together-video-generation') ) {
const svc_video = this.services.get('together-video-generation');
if ( typeof svc_video.models === 'function' ) {
items.push(...await svc_video.models());
}
}
return items;
});
res.json({ models });
} catch ( error ) {
this.log.error('Error fetching video model details:', error);
throw APIError.create('internal_server_error');
}
},
}).attach(router);
Endpoint({
route: '/video/models',
methods: ['GET'],
handler: async (req, res) => {
try {
const svc_su = this.services.get('su');
const models = await svc_su.sudo(async () => {
const items = [];
if ( this.services.has('openai-video-generation') ) {
const svc_video = this.services.get('openai-video-generation');
if ( typeof svc_video.models === 'function' ) {
items.push(...(await svc_video.models()).map(model => model.puterId || model.id));
}
}
if ( this.services.has('together-video-generation') ) {
const svc_video = this.services.get('together-video-generation');
if ( typeof svc_video.models === 'function' ) {
items.push(...(await svc_video.models()).map(model => model.id));
}
}
return items;
});
res.json({ models });
} catch ( error ) {
this.log.error('Error fetching video models:', error);
throw APIError.create('internal_server_error');
}
},
}).attach(router);
}
}
module.exports = {
ChatAPIService,
};
================================================
FILE: src/backend/src/services/ChatAPIService.test.js
================================================
/*
* 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 .
*/
/*
IMPORTANT NOTE ABOUT THIS UNIT TEST IN PARTICULAR
This was generated by AI, and I just wanted to see if I could get this
test working properly. It took me about a half hour, and then I got it
working using the DI mechanism provided by NodeModuleDIFeature.js.
So this DI mechanism works, and the test written by AI would have worked
perfectly on the first try if the AI knew about this DI mechanism.
That said, DO NOT REFERENCE THIS FILE FOR TEST CONVENTIONS.
Also, DO NOT SPEND MORE THAN AN HOUR MAINTAINING THIS. If you are
approaching an hour of maintanence effort, JUST DELETE THIS TEST;
it was written by AI, and fixed up as an experiment - it's not important.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Context } from '../util/context.js';
const { ChatAPIService } = require('./ChatAPIService');
describe('ChatAPIService', () => {
let chatApiService;
let mockServices;
let mockRouter;
let mockApp;
let mockSUService;
let mockAIChatService;
let mockEndpoint;
let mockWebServer;
let mockReq;
let mockRes;
beforeEach(() => {
// Mock AIChatService
mockAIChatService = {
list: () => ['model1', 'model2'],
models: () => [
{ id: 'model1', name: 'Model 1', cost: { input: 1, output: 2 } },
{ id: 'model2', name: 'Model 2', cost: { input: 3, output: 4 } },
],
};
// Mock SUService
mockSUService = {
sudo: vi.fn().mockImplementation(async (callback) => {
if ( typeof callback === 'function' ) {
return await callback();
}
return await mockSUService.sudo.mockImplementation(async (cb) => await cb());
}),
};
// Mock web server
mockWebServer = {
allow_undefined_origin: vi.fn(),
};
// Mock services
mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'su' ) return mockSUService;
if ( serviceName === 'ai-chat' ) return mockAIChatService;
if ( serviceName === 'web-server' ) return mockWebServer;
return null;
}),
};
// Mock router and app
mockRouter = {
use: vi.fn(),
get: vi.fn(),
post: vi.fn(),
};
mockApp = {
use: vi.fn(),
};
// Mock Endpoint function
mockEndpoint = vi.fn().mockReturnValue({
attach: vi.fn(),
});
// Mock request and response
mockReq = {};
mockRes = {
json: vi.fn(),
};
// Setup ChatAPIService
chatApiService = new ChatAPIService({
global_config: {},
config: {},
});
chatApiService.modules.Endpoint = mockEndpoint;
chatApiService.services = mockServices;
chatApiService.log = {
error: vi.fn(),
};
Context.root.set('services', mockServices);
// Mock the require function
const oldInstanceRequire_ = chatApiService.require;
chatApiService.require = vi.fn().mockImplementation((module) => {
if ( module === 'express' ) return { Router: () => mockRouter };
return oldInstanceRequire_.call(chatApiService, module);
});
});
describe('install_chat_endpoints_', () => {
it('should attach models endpoint to router', () => {
// Execute
chatApiService.install_chat_endpoints_({ router: mockRouter });
// Verify
expect(mockEndpoint).toHaveBeenCalledWith(expect.objectContaining({
route: '/chat/models',
methods: ['GET'],
}));
expect(mockEndpoint).toHaveBeenCalledWith(expect.objectContaining({
route: '/image/models',
methods: ['GET'],
}));
});
it('should attach models/details endpoint to router', () => {
// Setup
global.Endpoint = mockEndpoint;
// Execute
chatApiService.install_chat_endpoints_({ router: mockRouter });
// Verify
expect(mockEndpoint).toHaveBeenCalledWith(expect.objectContaining({
route: '/chat/models/details',
methods: ['GET'],
}));
expect(mockEndpoint).toHaveBeenCalledWith(expect.objectContaining({
route: '/image/models/details',
methods: ['GET'],
}));
});
});
describe('/models endpoint', () => {
it('should return list of models', async () => {
// Setup
global.Endpoint = mockEndpoint;
chatApiService.install_chat_endpoints_({ router: mockRouter });
// Get the handler function
const handler = mockEndpoint.mock.calls[0][0].handler;
// Execute
await handler(mockReq, mockRes);
// Verify
expect(mockSUService.sudo).toHaveBeenCalled();
expect(mockRes.json).toHaveBeenCalledWith({
models: mockAIChatService.list(),
});
});
});
describe('/models/details endpoint', () => {
it('should return detailed list of models', async () => {
// Setup
global.Endpoint = mockEndpoint;
chatApiService.install_chat_endpoints_({ router: mockRouter });
// Get the handler function
const handler = mockEndpoint.mock.calls[1][0].handler;
// Execute
await handler(mockReq, mockRes);
// Verify
expect(mockSUService.sudo).toHaveBeenCalled();
expect(mockRes.json).toHaveBeenCalledWith({
models: mockAIChatService.models(),
});
});
});
});
================================================
FILE: src/backend/src/services/CleanEmailService.js
================================================
/*
* 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 .
*/
const BaseService = require('./BaseService');
/**
* CleanEmailService - A service class for cleaning and validating email addresses
* Handles email normalization by applying provider-specific rules (e.g. Gmail's dot-insensitivity),
* manages subaddressing (plus addressing), and validates against blocked domains.
* Extends BaseService to integrate with the application's service infrastructure.
* @extends BaseService
*/
class CleanEmailService extends BaseService {
static NAMED_RULES = {
// For some providers, dots don't matter
dots_dont_matter: {
name: 'dots_dont_matter',
description: 'Dots don\'t matter',
rule: ({ eml }) => {
eml.local = eml.local.replace(/\./g, '');
},
},
remove_subaddressing: {
name: 'remove_subaddressing',
description: 'Remove subaddressing',
rule: ({ eml }) => {
eml.local = eml.local.split('+')[0];
},
},
};
static PROVIDERS = {
gmail: {
name: 'gmail',
description: 'Gmail',
rules: ['dots_dont_matter'],
},
icloud: {
name: 'icloud',
description: 'iCloud',
rules: ['dots_dont_matter'],
},
yahoo: {
name: 'yahoo',
description: 'Yahoo',
// Yahoo doesn't allow subaddressing, which would be a non-issue,
// except Yahoo allows '+' symbols in the primary email address.
rmrules: ['remove_subaddressing'],
},
};
// Service providers may have multiple subdomains a user can choose
static DOMAIN_TO_PROVIDER = {
'gmail.com': 'gmail',
'googlemail.com': 'gmail',
'yahoo.com': 'yahoo',
'yahoo.co.uk': 'yahoo',
'yahoo.ca': 'yahoo',
'yahoo.com.au': 'yahoo',
'icloud.com': 'icloud',
'me.com': 'icloud',
'mac.com': 'icloud',
};
// Service providers may allow the same primary email address to be
// used with different domains
static DOMAIN_NONDISTINCT = {
'googlemail.com': 'gmail.com',
};
/**
* Maps non-distinct email domains to their canonical equivalents.
* For example, 'googlemail.com' is mapped to 'gmail.com' since they
* represent the same email service.
* @type {Object.}
*/
_construct () {
this.named_rules = this.constructor.NAMED_RULES;
this.providers = this.constructor.PROVIDERS;
this.domain_to_provider = this.constructor.DOMAIN_TO_PROVIDER;
this.domain_nondistinct = this.constructor.DOMAIN_NONDISTINCT;
}
/**
* Cleans an email address by applying provider-specific rules and standardizations
* @param {string} email - The email address to clean
* @returns {string} The cleaned email address with applied rules and standardizations
*
* Splits email into local and domain parts, applies provider-specific rules like:
* - Removing dots for certain providers (Gmail, iCloud)
* - Handling subaddressing (removing +suffix)
* - Normalizing domains (e.g. googlemail.com -> gmail.com)
*/
clean (email) {
const eml = (() => {
const [local, domain] = email.split('@');
return { local, domain };
})();
if ( this.domain_nondistinct[eml.domain] ) {
eml.domain = this.domain_nondistinct[eml.domain];
}
const rules = [
'remove_subaddressing',
];
const provider = this.domain_to_provider[eml.domain] || eml.domain;
const provider_info = this.providers[provider];
if ( provider_info ) {
provider_info.rules = provider_info.rules || [];
provider_info.rmrules = provider_info.rmrules || [];
for ( const rule_name of provider_info.rules ) {
rules.push(rule_name);
}
for ( const rule_name of provider_info.rmrules ) {
const idx = rules.indexOf(rule_name);
if ( idx !== -1 ) {
rules.splice(idx, 1);
}
}
}
for ( const rule_name of rules ) {
const rule = this.named_rules[rule_name];
rule.rule({ eml });
}
return `${eml.local }@${ eml.domain}`;
}
/**
* Validates an email address against blocked domains and custom validation rules
* @param {string} email - The email address to validate
* @returns {Promise} True if email is valid, false if blocked or invalid
* @description First cleans the email, then checks against blocked domains from config.
* Emits 'email.validate' event to allow custom validation rules. Event handlers can
* set event.allow=false to reject the email.
*/
async validate (email) {
if ( this?.global_config?.env === 'dev' ) return true;
email = this.clean(email);
const config = this.global_config;
if ( Array.isArray(config.blocked_email_domains) ) {
for ( const suffix of config.blocked_email_domains ) {
if ( email.endsWith(suffix) ) {
return false;
}
}
}
const svc_event = this.services.get('event');
const event = { allow: true, email };
await svc_event.emit('email.validate', event);
if ( ! event.allow ) return false;
return true;
}
}
module.exports = { CleanEmailService };
================================================
FILE: src/backend/src/services/CleanEmailService.test.ts
================================================
import { describe, expect, it } from 'vitest';
import { createTestKernel } from '../../tools/test.mjs';
import { CleanEmailService } from './CleanEmailService.js';
describe('CleanEmailService', () => {
it('should clean email addresses correctly', async () => {
const testKernel = await createTestKernel({
serviceMap: {
'clean-email': CleanEmailService,
},
});
const cleanEmailService = testKernel.services!.get('clean-email') as CleanEmailService;
const cases = [
{
email: 'bob.ross+happy-clouds@googlemail.com',
expected: 'bobross@gmail.com',
},
{
email: 'under.rated+email-service@yahoo.com',
expected: 'under.rated+email-service@yahoo.com',
},
{
email: 'the-absolute+best@protonmail.com',
expected: 'the-absolute@protonmail.com',
},
];
for ( const { email, expected } of cases ) {
const cleaned = cleanEmailService.clean(email);
expect(cleaned).toBe(expected);
}
});
});
================================================
FILE: src/backend/src/services/ClientOperationService.js
================================================
/*
* 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 .
*/
const { Context } = require('../util/context');
// Key for tracing operations in the context, used for logging and tracking.
const CONTEXT_KEY = Context.make_context_key('operation-trace');
/**
* Class representing a tracker for individual client operations.
* The ClientOperationTracker class is designed to handle the metadata
* and attributes associated with each operation, allowing for better
* management and organization of client data during processing.
*/
class ClientOperationTracker {
constructor (parameters) {
this.name = parameters.name || 'untitled';
this.tags = parameters.tags || [];
this.frame = parameters.frame || null;
this.metadata = parameters.metadata || {};
this.objects = parameters.objects || [];
}
}
/**
* Class representing the ClientOperationService, which manages the
* operations related to client interactions. It provides methods to
* add new operations and handle their associated client operation
* trackers, ensuring efficient management and tracking of client-side
* operations during their lifecycle.
*/
class ClientOperationService {
constructor ({ services }) {
this.operations_ = [];
}
/**
* Adds a new operation to the service by creating a ClientOperationTracker instance.
*
* @param {Object} parameters - The parameters for the new operation.
* @returns {Promise} A promise that resolves to the created ClientOperationTracker instance.
*/
async add_operation (parameters) {
const tracker = new ClientOperationTracker(parameters);
return tracker;
}
ckey (key) {
return `${CONTEXT_KEY }:${ key}`;
}
}
module.exports = {
ClientOperationService,
};
================================================
FILE: src/backend/src/services/ClientOperationService.test.ts
================================================
import { describe, expect, it } from 'vitest';
import { ClientOperationService } from './ClientOperationService';
describe('ClientOperationService', async () => {
// ClientOperationService doesn't extend BaseService, so we can't use init
// We need to create it directly
const services = { _instances: {} };
const clientOperationService = new ClientOperationService({ services });
it('should be instantiated', () => {
expect(clientOperationService).toBeDefined();
expect(clientOperationService.operations_).toBeDefined();
});
it('should have operations array', () => {
expect(clientOperationService.operations_).toBeDefined();
expect(Array.isArray(clientOperationService.operations_)).toBe(true);
});
it('should create operation with default parameters', async () => {
const tracker = await clientOperationService.add_operation({});
expect(tracker).toBeDefined();
expect(tracker.name).toBe('untitled');
expect(Array.isArray(tracker.tags)).toBe(true);
expect(tracker.tags.length).toBe(0);
expect(tracker.frame).toBe(null);
expect(tracker.metadata).toBeDefined();
expect(typeof tracker.metadata).toBe('object');
expect(Array.isArray(tracker.objects)).toBe(true);
});
it('should create operation with name', async () => {
const tracker = await clientOperationService.add_operation({
name: 'test-operation',
});
expect(tracker.name).toBe('test-operation');
});
it('should create operation with tags', async () => {
const tags = ['tag1', 'tag2', 'tag3'];
const tracker = await clientOperationService.add_operation({
tags,
});
expect(tracker.tags).toEqual(tags);
});
it('should create operation with frame', async () => {
const frame = { type: 'test-frame' };
const tracker = await clientOperationService.add_operation({
frame,
});
expect(tracker.frame).toBe(frame);
});
it('should create operation with metadata', async () => {
const metadata = { key1: 'value1', key2: 'value2' };
const tracker = await clientOperationService.add_operation({
metadata,
});
expect(tracker.metadata).toEqual(metadata);
});
it('should create operation with objects', async () => {
const objects = [{ id: 1 }, { id: 2 }];
const tracker = await clientOperationService.add_operation({
objects,
});
expect(tracker.objects).toEqual(objects);
});
it('should create operation with all parameters', async () => {
const params = {
name: 'full-operation',
tags: ['full', 'test'],
frame: { type: 'frame' },
metadata: { meta: 'data' },
objects: [{ obj: 1 }],
};
const tracker = await clientOperationService.add_operation(params);
expect(tracker.name).toBe(params.name);
expect(tracker.tags).toEqual(params.tags);
expect(tracker.frame).toBe(params.frame);
expect(tracker.metadata).toEqual(params.metadata);
expect(tracker.objects).toEqual(params.objects);
});
it('should create multiple operations', async () => {
const tracker1 = await clientOperationService.add_operation({ name: 'op1' });
const tracker2 = await clientOperationService.add_operation({ name: 'op2' });
const tracker3 = await clientOperationService.add_operation({ name: 'op3' });
expect(tracker1.name).toBe('op1');
expect(tracker2.name).toBe('op2');
expect(tracker3.name).toBe('op3');
});
it('should have ckey method', () => {
expect(clientOperationService.ckey).toBeDefined();
expect(typeof clientOperationService.ckey).toBe('function');
});
it('should generate context key with ckey', () => {
const key = clientOperationService.ckey('test-key');
expect(key).toBeDefined();
expect(typeof key).toBe('string');
expect(key).toContain('test-key');
});
it('should generate different keys for different inputs', () => {
const key1 = clientOperationService.ckey('key1');
const key2 = clientOperationService.ckey('key2');
expect(key1).not.toBe(key2);
});
});
================================================
FILE: src/backend/src/services/CommandService.js
================================================
/*
* 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 .
*/
const { Context } = require('../util/context');
const BaseService = require('./BaseService');
/**
* Represents a Command class that encapsulates command execution functionality.
* Each Command instance contains a specification (spec) that defines its ID,
* name, description, handler function, and optional argument completer.
* The class provides methods for executing commands and handling command
* argument completion.
*/
class Command {
constructor (spec) {
this.spec_ = spec;
}
/**
* Gets the unique identifier for this command
* @returns {string} The command's ID as specified in the constructor
*/
get id () {
return this.spec_.id;
}
/**
* Executes the command with given arguments and logging
* @param {Array} args - Command arguments to pass to the handler
* @param {Object} [log=console] - Logger object for output, defaults to console
* @returns {Promise}
* @throws {Error} Logs any errors that occur during command execution
*/
async execute (args, log) {
log = log ?? console;
const { id, name, description, handler } = this.spec_;
try {
await handler(args, log);
} catch ( err ) {
log.error(`command ${name ?? id} failed: ${err.message}`);
log.error(err.stack);
}
}
completeArgument (args) {
const completer = this.spec_.completer;
if ( completer )
{
return completer(args);
}
return [];
}
}
/**
* CommandService class manages the registration, execution, and handling of commands in the Puter system.
* Extends BaseService to provide command-line interface functionality. Maintains a collection of Command
* objects, supports command registration with namespaces, command execution with arguments, and provides
* command lookup capabilities. Includes built-in help command functionality.
* @extends BaseService
*/
class CommandService extends BaseService {
/**
* Initializes the command service's internal state
* Called during service construction to set up the empty commands array
*/
async _construct () {
this.commands_ = [];
}
/**
* Add the help command to the list of commands on init
*/
async _init () {
this.commands_.push(new Command({
id: 'help',
description: 'show this help',
handler: (args, log) => {
log.log('available commands:');
for ( const command of this.commands_ ) {
log.log(`- ${command.spec_.id}: ${command.spec_.description}`);
}
},
}));
}
async '__on_boot.consolidation' () {
const svc_event = this.services.get('event');
const svc_command = this;
const event = {
createCommand (name, command) {
const serviceName = Context.get('extension_name') ?? '%missing%';
const commandSpec = typeof command === 'function'
? { handler: command }
: command;
if ( typeof commandSpec !== 'object' ) {
throw new Error('command must be either a function or an object');
}
if ( ! (typeof command.handler === 'function') ) {
throw new Error('command should have a handler function');
}
svc_command.registerCommands(serviceName, [{
id: name,
...commandSpec,
}]);
},
};
svc_event.emit('create.commands', event);
}
registerCommands (serviceName, commands) {
if ( ! this.log ) {
/* eslint-disable */
console.error(
'CommandService.registerCommands was called before a logger ' +
'was initialied. This happens when calling registerCommands ' +
'in the "construct" phase instead of the "init" phase. If ' +
'you are migrating a legacy service that does not extend ' +
'BaseService, maybe the _construct hook is calling init()'
);
/* eslint-enable */
process.exit(1);
}
for ( const command of commands ) {
this.log.debug(`registering command ${serviceName}:${command.id}`);
this.commands_.push(new Command({
...command,
id: `${serviceName}:${command.id}`,
}));
}
}
/**
* Executes a command with the given arguments and logging context
* @param {string[]} args - Array of command arguments where first element is command name
* @param {Object} log - Logger object for output (defaults to console if not provided)
* @returns {Promise}
* @throws {Error} If command execution fails
*/
async executeCommand (args, log) {
const [commandName, ...commandArgs] = args;
const command = this.commands_.find(c => c.spec_.id === commandName);
if ( ! command ) {
log.error(`unknown command: ${commandName}`);
return;
}
/**
* Executes a command with the given arguments in a global context
* @param {string[]} args - Array of command arguments where first element is command name
* @param {Object} log - Logger object for output
* @returns {Promise}
* @throws {Error} If command execution fails
*/
await globalThis.root_context.sub({
injected_logger: log,
}).arun(async () => {
await command.execute(commandArgs, log);
});
}
/**
* Executes a raw command string by splitting it into arguments and executing the command
* @param {string} text - Raw command string to execute
* @param {object} log - Logger object for output (defaults to console if not provided)
* @returns {Promise}
* @todo Replace basic whitespace splitting with proper tokenizer (obvious-json)
*/
async executeRawCommand (text, log) {
// TODO: add obvious-json as a tokenizer
const args = text.split(/\s+/);
await this.executeCommand(args, log);
}
/**
* Gets a list of all registered command names/IDs
* @returns {string[]} Array of command identifier strings
*/
get commandNames () {
return this.commands_.map(command => command.id);
}
getCommand (id) {
return this.commands_.find(command => command.id === id);
}
}
module.exports = {
CommandService,
};
================================================
FILE: src/backend/src/services/CommandService.test.ts
================================================
import { describe, expect, it, vi } from 'vitest';
import { createTestKernel } from '../../tools/test.mjs';
import { CommandService } from './CommandService';
describe('CommandService', async () => {
const testKernel = await createTestKernel({
serviceMap: {
commands: CommandService,
},
initLevelString: 'init',
});
const commandService = testKernel.services!.get('commands') as CommandService;
it('should be instantiated', () => {
expect(commandService).toBeInstanceOf(CommandService);
});
it('should have help command registered by default', () => {
expect(commandService.commandNames).toContain('help');
});
it('should register commands', () => {
commandService.registerCommands('test-service', [
{
id: 'test-cmd',
description: 'A test command',
handler: async () => {},
},
]);
expect(commandService.commandNames).toContain('test-service:test-cmd');
});
it('should execute registered commands', async () => {
let executed = false;
commandService.registerCommands('exec-test', [
{
id: 'exec-cmd',
description: 'Execute test',
handler: async () => { executed = true; },
},
]);
const mockLog = { error: vi.fn(), log: vi.fn() };
await commandService.executeCommand(['exec-test:exec-cmd'], mockLog);
expect(executed).toBe(true);
});
it('should pass arguments to command handler', async () => {
let receivedArgs: string[] = [];
commandService.registerCommands('args-test', [
{
id: 'args-cmd',
description: 'Args test',
handler: async (args) => { receivedArgs = args; },
},
]);
const mockLog = { error: vi.fn(), log: vi.fn() };
await commandService.executeCommand(['args-test:args-cmd', 'arg1', 'arg2'], mockLog);
expect(receivedArgs).toEqual(['arg1', 'arg2']);
});
it('should handle unknown commands', async () => {
const mockLog = { error: vi.fn(), log: vi.fn() };
await commandService.executeCommand(['unknown-command'], mockLog);
expect(mockLog.error).toHaveBeenCalledWith('unknown command: unknown-command');
});
it('should execute raw commands', async () => {
let executed = false;
commandService.registerCommands('raw-test', [
{
id: 'raw-cmd',
description: 'Raw test',
handler: async () => { executed = true; },
},
]);
const mockLog = { error: vi.fn(), log: vi.fn() };
await commandService.executeRawCommand('raw-test:raw-cmd', mockLog);
expect(executed).toBe(true);
});
it('should get command by id', () => {
commandService.registerCommands('get-test', [
{
id: 'get-cmd',
description: 'Get test',
handler: async () => {},
},
]);
const cmd = commandService.getCommand('get-test:get-cmd');
expect(cmd).toBeDefined();
expect(cmd?.id).toBe('get-test:get-cmd');
});
it('should execute help command', async () => {
const mockLog = { error: vi.fn(), log: vi.fn() };
await commandService.executeCommand(['help'], mockLog);
expect(mockLog.log).toHaveBeenCalledWith('available commands:');
});
it('should support command completers', () => {
commandService.registerCommands('complete-test', [
{
id: 'complete-cmd',
description: 'Complete test',
handler: async () => {},
completer: (args) => ['option1', 'option2'],
},
]);
const cmd = commandService.getCommand('complete-test:complete-cmd');
const completions = cmd?.completeArgument([]);
expect(completions).toEqual(['option1', 'option2']);
});
});
================================================
FILE: src/backend/src/services/ConfigurableCountingService.js
================================================
/*
* 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 .
*/
var crypto = require('crypto');
const BaseService = require('./BaseService');
const { Context } = require('../util/context');
const { DB_WRITE } = require('./database/consts');
const hash = v => {
const sum = crypto.createHash('sha1');
sum.update(v);
return sum.digest();
};
/**
* @class ConfigurableCountingService
* @extends BaseService
* @description The ConfigurableCountingService class extends BaseService and is responsible for managing and incrementing
* configurable counting types for different services.
* It defines counting types and SQL columns, and provides a method to increment counts based on specific service
* types and values. This class is used to manage usage counts for various services, ensuring accurate tracking
* and updating of counts in the database.
*/
class ConfigurableCountingService extends BaseService {
static counting_types = {
gpt: {
category: [
{
name: 'model',
type: 'string',
},
],
values: [
{
name: 'input_tokens',
type: 'uint',
},
{
name: 'output_tokens',
type: 'uint',
},
],
},
dalle: {
category: [
{
name: 'model',
type: 'string',
},
{
name: 'quality',
type: 'string',
},
{
name: 'resolution',
type: 'string',
},
],
},
};
static sql_columns = {
uint: [
'value_uint_1',
'value_uint_2',
'value_uint_3',
],
};
/**
* Initializes the database accessor for the ConfigurableCountingService.
* This method sets up the database service for writing counting data.
*
* @async
* @function _init
* @returns {Promise} A promise that resolves when the database connection is established.
* @memberof ConfigurableCountingService
*/
async _init () {
this.db = this.services.get('database').get(DB_WRITE, 'counting');
}
/**
* Increments the count for a given service based on the provided parameters.
* This method builds an SQL query to update the count and other custom values
* in the database. It handles different SQL dialects (MySQL and SQLite) and
* ensures that the pricing category is correctly hashed and stored.
*
* @param {Object} params - The parameters for incrementing the count.
* @param {string} params.service_name - The name of the service.
* @param {string} params.service_type - The type of the service.
* @param {Object} params.values - The values to be incremented.
* @throws {Error} If the service type is unknown or if there are no more available columns.
* @returns {Promise} A promise that resolves when the count is successfully incremented.
*/
async increment ({ service_name, service_type, values }) {
values = values ? { ...values } : {};
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth() + 1;
const counting_type = this.constructor.counting_types[service_type];
if ( ! counting_type ) {
throw new Error(`unknown counting type ${service_type}`);
}
const available_columns = {};
for ( const k in this.constructor.sql_columns ) {
available_columns[k] = [...this.constructor.sql_columns[k]];
}
const custom_col_names = counting_type.values.map((value, index) => {
const column = available_columns[value.type].shift();
if ( ! column ) {
// TODO: this could be an init check on all the available service types
throw new Error(`no more available columns for type ${value.type}`);
}
return column;
});
const custom_col_values = counting_type.values.map((value, index) => {
return values[value.name];
});
// `pricing_category` is a JSON field. Keys from `values` used for
// the pricing category will be removed from ths `values` object
const pricing_category = {};
for ( const category of counting_type.category ) {
pricing_category[category.name] = values[category.name];
delete values[category.name];
}
// `JSON.stringify` cannot be used here because it does not sort
// the keys.
const pricing_category_str = counting_type.category.map((category) => {
return `${category.name}:${pricing_category[category.name]}`;
}).join(',');
const pricing_category_hash = hash(pricing_category_str);
const actor = Context.get('actor');
const actor_key = actor.uid;
const required_data = {
year,
month,
service_name,
service_type,
actor_key,
pricing_category_hash,
pricing_category: JSON.stringify(pricing_category),
};
const duplicate_update_part =
`count = count + 1${
custom_col_names.length > 0 ? ', ' : ''
} ${
custom_col_names.map((name) => `${name} = ${name} + ?`).join(', ')
}`;
const identifying_keys = [
'year', 'month',
'service_type', 'service_name',
'actor_key',
'pricing_category_hash',
];
const sql =
`INSERT INTO monthly_usage_counts (${
Object.keys(required_data).join(', ')
}, count, ${
custom_col_names.join(', ')
}) ` +
`VALUES (${
Object.keys(required_data).map(() => '?').join(', ')
}, 1, ${custom_col_values.map(() => '?').join(', ')}) ${
this.db.case({
mysql: `ON DUPLICATE KEY UPDATE ${ duplicate_update_part}`,
sqlite: `ON CONFLICT(${
identifying_keys.map(v => `\`${v}\``).join(', ')
}) DO UPDATE SET ${duplicate_update_part}`,
})}`
;
const value_array = [
...Object.values(required_data),
...custom_col_values,
...custom_col_values,
];
await this.db.write(sql, value_array);
}
}
module.exports = {
ConfigurableCountingService,
};
================================================
FILE: src/backend/src/services/ConfigurableCountingService.test.ts
================================================
import { describe, expect, it } from 'vitest';
import { createTestKernel } from '../../tools/test.mjs';
import * as config from '../config';
import { ConfigurableCountingService } from './ConfigurableCountingService';
describe('ConfigurableCountingService', async () => {
config.load_config({
'services': {
'database': {
path: ':memory:',
},
},
});
const testKernel = await createTestKernel({
serviceMap: {
'counting': ConfigurableCountingService,
},
initLevelString: 'init',
testCore: true,
});
const countingService = testKernel.services!.get('counting') as ConfigurableCountingService;
it('should be instantiated', () => {
expect(countingService).toBeInstanceOf(ConfigurableCountingService);
});
it('should have counting types defined', () => {
expect(ConfigurableCountingService.counting_types).toBeDefined();
expect(ConfigurableCountingService.counting_types.gpt).toBeDefined();
expect(ConfigurableCountingService.counting_types.dalle).toBeDefined();
});
it('should have sql columns defined', () => {
expect(ConfigurableCountingService.sql_columns).toBeDefined();
expect(ConfigurableCountingService.sql_columns.uint).toBeDefined();
expect(ConfigurableCountingService.sql_columns.uint.length).toBe(3);
});
it('should validate GPT counting type structure', () => {
const gptType = ConfigurableCountingService.counting_types.gpt;
expect(gptType.category).toBeDefined();
expect(gptType.values).toBeDefined();
expect(gptType.category.length).toBeGreaterThan(0);
expect(gptType.values.length).toBeGreaterThan(0);
});
it('should validate DALL-E counting type structure', () => {
const dalleType = ConfigurableCountingService.counting_types.dalle;
expect(dalleType.category).toBeDefined();
expect(dalleType.category.length).toBeGreaterThan(0);
expect(dalleType.category.some(c => c.name === 'model')).toBe(true);
expect(dalleType.category.some(c => c.name === 'quality')).toBe(true);
expect(dalleType.category.some(c => c.name === 'resolution')).toBe(true);
});
it('should have gpt token value definitions', () => {
const gptType = ConfigurableCountingService.counting_types.gpt;
expect(gptType.values.some(v => v.name === 'input_tokens')).toBe(true);
expect(gptType.values.some(v => v.name === 'output_tokens')).toBe(true);
expect(gptType.values.every(v => v.type === 'uint')).toBe(true);
});
it('should have available sql columns for uint type', () => {
const columns = ConfigurableCountingService.sql_columns.uint;
expect(columns).toBeDefined();
expect(Array.isArray(columns)).toBe(true);
expect(columns.length).toBe(3);
expect(columns.every(col => typeof col === 'string')).toBe(true);
});
it('should have model category for gpt', () => {
const gptType = ConfigurableCountingService.counting_types.gpt;
const modelCategory = gptType.category.find(c => c.name === 'model');
expect(modelCategory).toBeDefined();
expect(modelCategory!.type).toBe('string');
});
});
================================================
FILE: src/backend/src/services/Container.js
================================================
/*
* 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 .
*/
const { AdvancedBase } = require('@heyputer/putility');
const config = require('../config');
const { Context } = require('../util/context');
const { CompositeError } = require('../util/errorutil');
const { TeePromise } = require('@heyputer/putility').libs.promise;
// 17 lines of code instead of an entire dependency-injection framework
/**
* The `Container` class is a lightweight dependency-injection container designed to manage
* service instances within the application. It provides functionality for registering,
* retrieving, and managing the lifecycle of services, including initialization and event
* handling. This class is intended to simplify dependency management and ensure that services
* are properly initialized and available throughout the application.
*
* @class
*/
class Container {
constructor ({ logger }) {
this.logger = logger;
this.instances_ = {};
this.implementors_ = {};
this.ready = new TeePromise();
this.modname_ = null;
this.modules_ = {};
this.enforcers = [];
}
/**
*
* @param {(object: {name: string, options: any, meta: {disallow: boolean|undefined}})=>void} func
*/
registerEnforcer (func) {
this.enforcers.push(func);
}
registerModule (name, module) {
this.modules_[name] = {
services_l: [],
services_m: {},
module,
};
this.setModuleName(name);
}
/**
* Sets the name of the current module registering services.
*
* Note: this is an antipattern; it would be a bit better to
* provide the module name while registering a service, but
* this requires making an implementor of Container's interface
* with this as a hidden variable so as not to break existing
* modules.
*/
setModuleName (name) {
this.modname_ = name;
}
/**
* registerService registers a service with the services container.
*
* @param {String} name - the name of the service
* @param {BaseService.constructor} cls - an implementation of BaseService
* @param {Array} args - arguments to pass to the service constructor
*/
registerService (name, cls, args) {
const my_config = config.services?.[name] || {};
const instance = cls.getInstance
? cls.getInstance({ services: this, config, my_config, name, args })
: new cls({
context: Context.get(),
services: this,
config,
my_config,
name,
args,
}) ;
this.instances_[name] = instance;
if ( this.modname_ ) {
const mod_entry = this.modules_[this.modname_];
mod_entry.services_l.push(name);
mod_entry.services_m[name] = true;
}
if ( ! (instance instanceof AdvancedBase) ) return;
const traits = instance.list_traits();
for ( const trait of traits ) {
if ( ! this.implementors_[trait] ) {
this.implementors_[trait] = [];
}
this.implementors_[trait].push({
name,
instance,
impl: instance.as(trait),
});
}
}
/**
* patchService allows overriding methods on a service that is already
* constructed and initialized.
*
* @param {String} name - the name of the service to patch
* @param {ServicePatch.constructor} patch - the patch
* @param {Array} args - arguments to pass to the patch
*/
patchService (name, patch, args) {
const original_service = this.instances_[name];
const patch_instance = new patch();
patch_instance.patch({ original_service, args });
}
// get_implementors returns a list of implementors for the specified
// interface name.
get_implementors (interface_name) {
const internal_list = this.implementors_[interface_name];
const clone = [...internal_list];
return clone;
}
set (name, instance) {
this.instances_[name] = instance;
}
_get (name, opts) {
if ( this.instances_[name] ) {
return this.instances_[name];
}
if ( ! opts?.optional ) {
throw new Error(`missing service: ${name}`);
}
}
get (name, opts) {
let meta = {};
// Extensions should be allowed to (synchronously) guard extensions and access to them
this.enforcers.forEach(func => {
func({ name, opts, meta });
});
if ( ! meta.disallow ) {
return this._get(name, opts);
}
}
/**
* Checks if a service is registered in the container.
*
* @param {String} name - The name of the service to check.
* @returns {Boolean} - Returns true if the service is registered, false otherwise.
*/
has (name) {
return !!this.instances_[name];
}
get values () {
const values = {};
for ( const k in this.instances_ ) {
let k2 = k;
// Replace lowerCamelCase with underscores
// (just an idea; more effort than it's worth right now)
// let k2 = k.replace(/([a-z])([A-Z])/g, '$1_$2')
// Replace dashes with underscores
k2 = k2.replace(/-/g, '_');
// Convert to lower case
k2 = k2.toLowerCase();
values[k2] = this.instances_[k];
}
return this.instances_;
}
/**
* Initializes all registered services in the container.
*
* This method first constructs each service by calling its `construct` method,
* and then initializes each service by calling its `init` method. If any service
* initialization fails, it logs the failures and throws a `CompositeError`
* containing details of all failed initializations.
*
* @returns {Promise} A promise that resolves when all services are
* initialized or rejects if any service initialization fails.
*/
async init () {
for ( const k in this.instances_ ) {
if ( ! this.instances_[k]._run_as_early_as_possible ) continue;
await this.instances_[k].run_as_early_as_possible();
}
for ( const k in this.instances_ ) {
await this.instances_[k].construct();
}
const init_failures = [];
const promises = [];
const PARALLEL = config.experimental_parallel_init;
for ( const k in this.instances_ ) {
try {
if ( PARALLEL ) promises.push(this.instances_[k].init());
else await this.instances_[k].init();
} catch (e) {
init_failures.push({ k, e });
}
}
if ( PARALLEL ) await Promise.all(promises);
if ( init_failures.length ) {
console.error('init failures', init_failures);
throw new CompositeError(`failed to initialize these services: ${
init_failures.map(({ k }) => k).join(', ')}`,
init_failures.map(({ k, e }) => e));
}
}
/**
* Emits an event to all registered services.
*
* This method sends an event identified by `id` along with any additional arguments to all
* services registered in the container. If a logger is available, it logs the event.
*
* @param {string} id - The identifier of the event.
* @param {...*} args - Additional arguments to pass to the event handler.
* @returns {Promise} A promise that resolves when all event handlers have completed.
*/
async emit (id, ...args) {
if ( this.logger ) {
this.logger.debug(`services:event ${id}`, { args });
}
const promises = [];
for ( const k in this.instances_ ) {
if ( this.instances_[k].__on ) {
promises.push(Context.arun(() => this.instances_[k].__on(id, args)));
}
}
await Promise.all(promises);
}
}
/**
* @class ProxyContainer
* @classdesc The ProxyContainer class is a proxy for the Container class, allowing for delegation of service management tasks.
* It extends the functionality of the Container class by providing a delegation mechanism.
* This class is useful for scenarios where you need to manage services through a proxy,
* enabling additional flexibility and control over service instances.
*/
class ProxyContainer {
constructor (delegate) {
this.delegate = delegate;
this.instances_ = {};
}
set (name, instance) {
this.instances_[name] = instance;
}
get (name) {
if ( this.instances_.hasOwnProperty(name) ) {
return this.instances_[name];
}
return this.delegate.get(name);
}
/**
* Checks if the container has a service with the specified name.
*
* @param {string} name - The name of the service to check.
* @returns {boolean} - Returns true if the service exists, false otherwise.
*/
has (name) {
if ( this.instances_.hasOwnProperty(name) ) {
return true;
}
return this.delegate.has(name);
}
get values () {
const values = {};
Object.assign(values, this.delegate.values);
for ( const k in this.instances_ ) {
let k2 = k;
// Replace lowerCamelCase with underscores
// (just an idea; more effort than it's worth right now)
// let k2 = k.replace(/([a-z])([A-Z])/g, '$1_$2')
// Replace dashes with underscores
k2 = k2.replace(/-/g, '_');
// Convert to lower case
k2 = k2.toLowerCase();
values[k2] = this.instances_[k];
}
return values;
}
}
module.exports = { Container, ProxyContainer };
================================================
FILE: src/backend/src/services/ContextInitService.js
================================================
/*
* 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 .
*/
const { Context } = require('../util/context');
const BaseService = require('./BaseService');
// DRY: (2/3) - src/util/context.js; move install() to base class
/**
* @class ContextInitExpressMiddleware
* @description Express middleware that initializes context values for requests.
* Manages a collection of value initializers that can be synchronous values
* or asynchronous factory functions. Each initializer sets a key-value pair
* in the request context. Part of a DRY implementation shared with context.js.
* TODO: Consider moving install() method to base class.
*/
class ContextInitExpressMiddleware {
/**
* Express middleware class that initializes context values for requests
*
* Manages a list of value initializers that populate the Context with
* either static values or async-generated values when handling requests.
* Part of DRY pattern with src/util/context.js.
*/
constructor () {
this.value_initializers_ = [];
}
register_initializer (initializer) {
this.value_initializers_.push(initializer);
}
install (app) {
app.use(this.run.bind(this));
}
/**
* Installs the middleware into the Express application
* @param {Express} app - The Express application instance
* @returns {void}
*/
async run (req, res, next) {
const x = Context.get();
for ( const initializer of this.value_initializers_ ) {
if ( initializer.value ) {
x.set(initializer.key, initializer.value);
} else if ( initializer.async_factory ) {
x.set(initializer.key, await initializer.async_factory());
}
}
next();
}
}
/**
* @class ContextInitService
* @extends BaseService
* @description Service responsible for initializing and managing context values in the application.
* Provides methods to register both synchronous values and asynchronous factories for context
* initialization. Works in conjunction with Express middleware to ensure proper context setup
* for each request. Extends BaseService to integrate with the application's service architecture.
*/
class ContextInitService extends BaseService {
/**
* Service for initializing request context with values and async factories.
* Extends BaseService to provide middleware for Express that populates the Context
* with registered values and async-generated values at the start of each request.
*
* @extends BaseService
*/
_construct () {
this.mw = new ContextInitExpressMiddleware();
}
register_value (key, value) {
this.mw.register_initializer({
key, value,
});
}
/**
* Registers an asynchronous factory function to initialize a context value
* @param {string} key - The key to store the value under in the context
* @param {Function} async_factory - Async function that returns the value to store
*/
register_async_factory (key, async_factory) {
this.mw.register_initializer({
key, async_factory,
});
}
async '__on_install.middlewares.context-aware' (_, { app }) {
this.mw.install(app);
await this.services.emit('install.context-initializers');
}
}
module.exports = {
ContextInitService,
};
================================================
FILE: src/backend/src/services/ContextInitService.test.ts
================================================
import { describe, expect, it } from 'vitest';
import { createTestKernel } from '../../tools/test.mjs';
import { ContextInitService } from './ContextInitService';
describe('ContextInitService', async () => {
const testKernel = await createTestKernel({
serviceMap: {
'context-init': ContextInitService,
},
initLevelString: 'init',
});
const contextInitService = testKernel.services!.get('context-init') as any;
it('should be instantiated', () => {
expect(contextInitService).toBeInstanceOf(ContextInitService);
});
it('should have middleware instance', () => {
expect(contextInitService.mw).toBeDefined();
expect(contextInitService.mw.value_initializers_).toBeDefined();
expect(Array.isArray(contextInitService.mw.value_initializers_)).toBe(true);
});
it('should register a value initializer', () => {
const initialLength = contextInitService.mw.value_initializers_.length;
contextInitService.register_value('test-key', 'test-value');
expect(contextInitService.mw.value_initializers_.length).toBe(initialLength + 1);
});
it('should store key-value pair in initializer', () => {
const service = testKernel.services!.get('context-init') as any;
service.register_value('stored-key', 'stored-value');
const lastInitializer = service.mw.value_initializers_[service.mw.value_initializers_.length - 1];
expect(lastInitializer.key).toBe('stored-key');
expect(lastInitializer.value).toBe('stored-value');
});
it('should register async factory', () => {
const service = testKernel.services!.get('context-init') as any;
const initialLength = service.mw.value_initializers_.length;
const factory = async () => 'async-value';
service.register_async_factory('async-key', factory);
expect(service.mw.value_initializers_.length).toBe(initialLength + 1);
});
it('should store async factory in initializer', () => {
const service = testKernel.services!.get('context-init') as any;
const factory = async () => 'factory-result';
service.register_async_factory('factory-key', factory);
const lastInitializer = service.mw.value_initializers_[service.mw.value_initializers_.length - 1];
expect(lastInitializer.key).toBe('factory-key');
expect(lastInitializer.async_factory).toBe(factory);
});
it('should handle multiple value registrations', () => {
const service = testKernel.services!.get('context-init') as any;
service.register_value('key1', 'value1');
service.register_value('key2', 'value2');
service.register_value('key3', 'value3');
const keys = service.mw.value_initializers_.map((init: any) => init.key);
expect(keys).toContain('key1');
expect(keys).toContain('key2');
expect(keys).toContain('key3');
});
it('should have install method on middleware', () => {
expect(contextInitService.mw.install).toBeDefined();
expect(typeof contextInitService.mw.install).toBe('function');
});
it('should have run method on middleware', () => {
expect(contextInitService.mw.run).toBeDefined();
expect(typeof contextInitService.mw.run).toBe('function');
});
});
================================================
FILE: src/backend/src/services/DetailProviderService.js
================================================
/*
* 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 .
*/
const BaseService = require('./BaseService');
/**
* A generic service class for any service that enables registering
* detail providers. A detail provider is a function that takes an
* input object and uses its values to populate another object.
*/
class DetailProviderService extends BaseService {
_construct () {
this.providers_ = [];
}
register_provider (fn) {
this.providers_.push(fn);
}
/**
* Asynchronously retrieves details by invoking registered detail providers
* in list. Populates the provided output object with the results of
* each provider. If no output object is provided, a new one is created
* by default.
*
* @param {Object} context - The context object containing input data for
* the providers.
* @param {Object} [out={}] - An optional output object to populate with
* the details.
* @returns {Promise} The populated output object after all
* providers have been processed.
*/
async get_details (context, out) {
out = out || {};
for ( const provider of this.providers_ ) {
await provider(context, out);
}
return out;
}
}
module.exports = { DetailProviderService };
================================================
FILE: src/backend/src/services/DetailProviderService.test.ts
================================================
import { describe, expect, it } from 'vitest';
import { createTestKernel } from '../../tools/test.mjs';
import { DetailProviderService } from './DetailProviderService';
describe('DetailProviderService', async () => {
const testKernel = await createTestKernel({
serviceMap: {
'detail-provider': DetailProviderService,
},
initLevelString: 'init',
});
const detailProviderService = testKernel.services!.get('detail-provider') as any;
it('should be instantiated', () => {
expect(detailProviderService).toBeInstanceOf(DetailProviderService);
});
it('should have empty providers array initially', () => {
expect(detailProviderService.providers_).toBeDefined();
expect(Array.isArray(detailProviderService.providers_)).toBe(true);
});
it('should register a provider', () => {
const initialLength = detailProviderService.providers_.length;
const provider = async (context: any, out: any) => {
out.test = 'value';
};
detailProviderService.register_provider(provider);
expect(detailProviderService.providers_.length).toBe(initialLength + 1);
});
it('should get details with single provider', async () => {
const service = testKernel.services!.get('detail-provider') as any;
service.register_provider(async (context: any, out: any) => {
out.name = context.input;
});
const result = await service.get_details({ input: 'test-name' });
expect(result.name).toBe('test-name');
});
it('should get details with multiple providers', async () => {
const service = testKernel.services!.get('detail-provider') as any;
service.register_provider(async (context: any, out: any) => {
out.field1 = 'value1';
});
service.register_provider(async (context: any, out: any) => {
out.field2 = 'value2';
});
const result = await service.get_details({});
expect(result.field1).toBe('value1');
expect(result.field2).toBe('value2');
});
it('should allow providers to modify existing output', async () => {
const service = testKernel.services!.get('detail-provider') as any;
service.register_provider(async (context: any, out: any) => {
out.counter = 1;
});
service.register_provider(async (context: any, out: any) => {
out.counter = out.counter + 1;
});
const result = await service.get_details({});
expect(result.counter).toBe(2);
});
it('should use provided output object', async () => {
const service = testKernel.services!.get('detail-provider') as any;
service.register_provider(async (context: any, out: any) => {
out.added = true;
});
const existingOut = { existing: 'value' };
const result = await service.get_details({}, existingOut);
expect(result.existing).toBe('value');
expect(result.added).toBe(true);
});
it('should handle async providers', async () => {
const service = testKernel.services!.get('detail-provider') as any;
service.register_provider(async (context: any, out: any) => {
await new Promise(resolve => setTimeout(resolve, 10));
out.async = true;
});
const result = await service.get_details({});
expect(result.async).toBe(true);
});
});
================================================
FILE: src/backend/src/services/DynamoKVStore/.gitignore
================================================
*.js
*.js.map
================================================
FILE: src/backend/src/services/DynamoKVStore/DynamoKVStore.test.ts
================================================
import { Actor } from '@heyputer/backend/src/services/auth/Actor.js';
import { SUService } from '@heyputer/backend/src/services/SUService';
import { createTestKernel } from '@heyputer/backend/tools/test.mjs';
import { describe, expect, it } from 'vitest';
import { config } from '../../loadTestConfig.js';
import { DynamoKVStore } from './DynamoKVStore.js';
import { DynamoKVStoreWrapper, IDynamoKVStoreWrapper } from './DynamoKVStoreWrapper.js';
describe('DynamoKVStore', async () => {
const TABLE_NAME = 'store-kv-v1';
const makeActor = (userId: number | string, appUid?: string) => ({
type: {
user: { id: userId, uuid: String(userId) },
...(appUid ? { app: { uid: appUid } } : {}),
},
}) as Actor;
const testKernel = await createTestKernel({
serviceMap: {
'puter-kvstore': DynamoKVStoreWrapper,
},
initLevelString: 'init',
testCore: true,
serviceConfigOverrideMap: {
'services': {
'puter-kvstore': { tableName: TABLE_NAME },
},
},
});
const testSubject = testKernel.services!.get('puter-kvstore') as IDynamoKVStoreWrapper;
const kvStore = testSubject.kvStore!;
const su = testKernel.services!.get('su') as SUService;
it('should be instantiated', () => {
expect(testSubject).toBeInstanceOf(DynamoKVStoreWrapper);
});
it('should contain a copy of the public methods of DynamoKVStore too', () => {
const meteringMethods = Object.getOwnPropertyNames(DynamoKVStore.prototype)
.filter((name) => name !== 'constructor');
const wrapperMethods = testSubject as unknown as Record;
const missing = meteringMethods.filter((name) => typeof wrapperMethods[name] !== 'function');
expect(missing).toEqual([]);
});
it('should have DynamoKVStore instantiated', async () => {
expect(testSubject.kvStore).toBeInstanceOf(DynamoKVStore);
});
it('sets and retrieves values for the current actor context', async () => {
const actor = makeActor(1);
const key = 'greeting';
const value = { hello: 'world' };
await su.sudo(actor, () => kvStore.set({ key, value }));
const stored = await su.sudo(actor, () => kvStore.get({ key }));
expect(stored).toEqual(value);
});
it('scopes data to the app when provided', async () => {
const userId = 2;
const actorAppOne = makeActor(userId, 'app-one');
const actorAppTwo = makeActor(userId, 'app-two');
const key = 'scoped-key';
await su.sudo(actorAppOne, () => kvStore.set({ key, value: 'one' }));
await su.sudo(actorAppTwo, () => kvStore.set({ key, value: 'two' }));
const fromOne = await su.sudo(actorAppOne, () => kvStore.get({ key }));
const fromTwo = await su.sudo(actorAppTwo, () => kvStore.get({ key }));
expect(fromOne).toBe('one');
expect(fromTwo).toBe('two');
});
it('increments nested numeric paths and persists the aggregated totals', async () => {
const actor = makeActor(3);
const key = 'counter-key';
const first = await su.sudo(actor, () => kvStore.incr({
key,
pathAndAmountMap: { 'total': 5, 'nested.count': 2 },
}));
const second = await su.sudo(actor, () => kvStore.incr({
key,
pathAndAmountMap: { 'total': 1, 'nested.count': 3 },
}));
expect(first).toMatchObject({ total: 5, nested: { count: 2 } });
expect(second).toMatchObject({ total: 6, nested: { count: 5 } });
const persisted = await su.sudo(actor, () => kvStore.get({ key }));
expect(persisted).toMatchObject({ total: 6, nested: { count: 5 } });
});
it('decrements numeric paths via decr and keeps values in sync', async () => {
const actor = makeActor(4);
const key = 'decr-key';
await su.sudo(actor, () => kvStore.incr({
key,
pathAndAmountMap: { total: 5, 'nested.count': 4 },
}));
const afterDecr = await su.sudo(actor, () => kvStore.decr({
key,
pathAndAmountMap: { total: 2, 'nested.count': 1 },
}));
expect(afterDecr).toMatchObject({ total: 3, nested: { count: 3 } });
const persisted = await su.sudo(actor, () => kvStore.get({ key }));
expect(persisted).toMatchObject({ total: 3, nested: { count: 3 } });
});
it('deletes keys with del', async () => {
const actor = makeActor(5);
const key = 'delete-me';
await su.sudo(actor, () => {
return kvStore.set({ key, value: 'bye' });
});
const res = await su.sudo(actor, () => kvStore.del({ key }));
const value = await su.sudo(actor, () => kvStore.get({ key }));
expect(res).toBe(true);
expect(value).toBeNull();
});
it('lists entries, keys, and values while omitting expired rows', async () => {
const actor = makeActor(6);
await su.sudo(actor, () => kvStore.set({ key: 'k1', value: 'v1' }));
await su.sudo(actor, () => kvStore.set({ key: 'expired', value: 'gone', expireAt: Math.floor(Date.now() / 1000) - 10 }));
const entries = await su.sudo(actor, () => kvStore.list({ as: 'entries' }));
const keys = await su.sudo(actor, () => kvStore.list({ as: 'keys' }));
const values = await su.sudo(actor, () => kvStore.list({ as: 'values' }));
expect(entries).toEqual([{ key: 'k1', value: 'v1' }]);
expect(keys).toEqual(['k1']);
expect(values).toEqual(['v1']);
});
it('rejects invalid list selector', async () => {
const actor = makeActor(7);
expect(su.sudo(actor, () => kvStore.list({ as: 'bad' as never })))
.rejects;
});
it('supports paginated list results with cursors', async () => {
const actor = makeActor(71);
await su.sudo(actor, () => kvStore.set({ key: 'a', value: 1 }));
await su.sudo(actor, () => kvStore.set({ key: 'b', value: 2 }));
await su.sudo(actor, () => kvStore.set({ key: 'c', value: 3 }));
const firstPage = await su.sudo(actor, () => kvStore.list({ as: 'keys', limit: 2 })) as { items: string[]; cursor?: string };
expect(firstPage.items).toHaveLength(2);
expect(firstPage.cursor).toBeTypeOf('string');
const secondPage = await su.sudo(actor, () => kvStore.list({ as: 'keys', limit: 2, cursor: firstPage.cursor })) as { items: string[]; cursor?: string };
expect(secondPage.items).toHaveLength(1);
expect(secondPage.cursor).toBeUndefined();
const allKeys = [...firstPage.items, ...secondPage.items].sort();
expect(allKeys).toEqual(['a', 'b', 'c']);
});
it('supports prefix pattern semantics', async () => {
const actor = makeActor(72);
const allKeys = [
'abc',
'abc123',
'abc123xyz',
'ab',
'key*literal',
'key*literal-2',
'k*y',
'k*y-extra',
'other',
];
await Promise.all(allKeys.map((key, idx) => su.sudo(actor, () => kvStore.set({ key, value: idx }))));
const expectedAbc = ['abc', 'abc123', 'abc123xyz'];
const expectedKeyStar = ['key*literal', 'key*literal-2'];
const expectedMiddleStar = ['k*y', 'k*y-extra'];
const abcKeys = await su.sudo(actor, () => kvStore.list({ as: 'keys', pattern: 'abc' })) as string[];
expect([...abcKeys].sort()).toEqual([...expectedAbc].sort());
const abcWildcardKeys = await su.sudo(actor, () => kvStore.list({ as: 'keys', pattern: 'abc*' })) as string[];
expect([...abcWildcardKeys].sort()).toEqual([...expectedAbc].sort());
const keyStarKeys = await su.sudo(actor, () => kvStore.list({ as: 'keys', pattern: 'key**' })) as string[];
expect([...keyStarKeys].sort()).toEqual([...expectedKeyStar].sort());
const middleStarKeys = await su.sudo(actor, () => kvStore.list({ as: 'keys', pattern: 'k*y*' })) as string[];
expect([...middleStarKeys].sort()).toEqual([...expectedMiddleStar].sort());
const allList = await su.sudo(actor, () => kvStore.list({ as: 'keys', pattern: '*' })) as string[];
expect([...allList].sort()).toEqual([...allKeys].sort());
});
it('returns ordered values for arrays and null for expired keys', async () => {
const actor = makeActor(8);
const now = Math.floor(Date.now() / 1000);
await su.sudo(actor, () => kvStore.set({ key: 'a', value: 1 }));
await su.sudo(actor, () => kvStore.set({ key: 'b', value: 2, expireAt: now - 5 }));
await su.sudo(actor, () => kvStore.set({ key: 'c', value: 3 }));
const results = await su.sudo(actor, () => kvStore.get({ key: ['c', 'b', 'a'] }));
expect(results).toEqual([3, null, 1]);
});
it('flush clears all keys for the actor/app combination', async () => {
const actor = makeActor(9, 'flush-app');
await su.sudo(actor, () => kvStore.set({ key: 'one', value: 1 }));
await su.sudo(actor, () => kvStore.set({ key: 'two', value: 2 }));
const res = await su.sudo(actor, () => kvStore.flush());
const remaining = await su.sudo(actor, () => kvStore.list({ as: 'entries' }));
expect(res).toBe(true);
expect(remaining).toEqual([]);
});
it('expireAt and expire set timestamps that cause reads to return null', async () => {
const actor = makeActor(10);
const keyAt = 'expire-at';
const keyTtl = 'expire-ttl';
await su.sudo(actor, () => kvStore.set({ key: keyAt, value: 'keep' }));
await su.sudo(actor, () => kvStore.set({ key: keyTtl, value: 'keep' }));
await su.sudo(actor, () => kvStore.expireAt({ key: keyAt, timestamp: Math.floor(Date.now() / 1000) - 1 }));
await su.sudo(actor, () => kvStore.expire({ key: keyTtl, ttl: -1 }));
const valAt = await su.sudo(actor, () => kvStore.get({ key: keyAt }));
const valTtl = await su.sudo(actor, () => kvStore.get({ key: keyTtl }));
expect(valAt).toBeNull();
expect(valTtl).toBeNull();
});
it('updates nested paths and creates missing maps', async () => {
const actor = makeActor(12);
const key = 'update-key';
const updated = await su.sudo(actor, () => kvStore.update({
key,
pathAndValueMap: {
'profile.name': 'Ada',
'profile.stats.score': 7,
'active': true,
},
}));
expect(updated).toMatchObject({
profile: { name: 'Ada', stats: { score: 7 } },
active: true,
});
const stored = await su.sudo(actor, () => kvStore.get({ key }));
expect(stored).toMatchObject({
profile: { name: 'Ada', stats: { score: 7 } },
active: true,
});
});
it('update can set ttl for the whole object', async () => {
const actor = makeActor(13);
const key = 'update-ttl';
await su.sudo(actor, () => kvStore.update({
key,
pathAndValueMap: { 'count': 1 },
ttl: -1,
}));
const stored = await su.sudo(actor, () => kvStore.get({ key }));
expect(stored).toBeNull();
});
it('supports list index paths when updating', async () => {
const actor = makeActor(17);
const key = 'update-list-index';
await su.sudo(actor, () => kvStore.set({
key,
value: { a: { b: [1, 2] } },
}));
const updated = await su.sudo(actor, () => kvStore.update({
key,
pathAndValueMap: { 'a.b[1]': 5 },
}));
expect((updated as { a?: { b?: number[] } }).a?.b).toEqual([1, 5]);
const stored = await su.sudo(actor, () => kvStore.get({ key }));
expect((stored as { a?: { b?: number[] } }).a?.b).toEqual([1, 5]);
});
it('adds values to nested lists and creates missing maps', async () => {
const actor = makeActor(15);
const key = 'add-key';
const first = await su.sudo(actor, () => kvStore.add({
key,
pathAndValueMap: {
'a.b': 1,
},
}));
expect(first).toMatchObject({ a: { b: [1] } });
const second = await su.sudo(actor, () => kvStore.add({
key,
pathAndValueMap: {
'a.b': 2,
'a.c': ['x', 'y'],
},
}));
expect(second).toMatchObject({ a: { b: [1, 2], c: ['x', 'y'] } });
const stored = await su.sudo(actor, () => kvStore.get({ key }));
expect(stored).toMatchObject({ a: { b: [1, 2], c: ['x', 'y'] } });
});
it('supports list index paths when appending', async () => {
const actor = makeActor(18);
const key = 'add-list-index';
await su.sudo(actor, () => kvStore.set({
key,
value: { a: { b: [[1], [2]] } },
}));
const updated = await su.sudo(actor, () => kvStore.add({
key,
pathAndValueMap: { 'a.b[1]': 3 },
}));
expect((updated as { a?: { b?: number[][] } }).a?.b).toEqual([[1], [2, 3]]);
const stored = await su.sudo(actor, () => kvStore.get({ key }));
expect((stored as { a?: { b?: number[][] } }).a?.b).toEqual([[1], [2, 3]]);
});
it('supports nested list indexing for add, update, remove, and incr', async () => {
const actor = makeActor(21);
const key = 'nested-list-index';
await su.sudo(actor, () => kvStore.set({
key,
value: { a: [1, { b: { c: [1] } }, 2] },
}));
const added = await su.sudo(actor, () => kvStore.add({
key,
pathAndValueMap: { 'a[1].b.c': 2 },
}));
expect((added as { a?: Array }).a).toEqual([1, { b: { c: [1, 2] } }, 2]);
const updated = await su.sudo(actor, () => kvStore.update({
key,
pathAndValueMap: { 'a[1].b.c': [9] },
}));
expect((updated as { a?: Array }).a).toEqual([1, { b: { c: [9] } }, 2]);
const removed = await su.sudo(actor, () => kvStore.remove({
key,
paths: ['a[1].b.c'],
}));
expect((removed as { a?: Array }).a).toEqual([1, { b: {} }, 2]);
await su.sudo(actor, () => kvStore.set({
key,
value: { a: [1, { b: { c: 1 } }, 2] },
}));
const incrRes = await su.sudo(actor, () => kvStore.incr({
key,
pathAndAmountMap: { 'a[1].b.c': 3 },
}));
expect((incrRes as { a?: Array }).a).toEqual([1, { b: { c: 4 } }, 2]);
});
it('removes nested values including indexed list paths', async () => {
const actor = makeActor(19);
const key = 'remove-list-index';
await su.sudo(actor, () => kvStore.set({
key,
value: { a: { b: [1, 2, 3], c: { d: 4 }, e: 'keep' } },
}));
const updated = await su.sudo(actor, () => kvStore.remove({
key,
paths: ['a.b[1]', 'a.c'],
}));
expect((updated as { a?: { b?: number[]; e?: string } }).a).toEqual({ b: [1, 3], e: 'keep' });
const stored = await su.sudo(actor, () => kvStore.get({ key }));
expect((stored as { a?: { b?: number[]; e?: string } }).a).toEqual({ b: [1, 3], e: 'keep' });
});
it('rejects overlapping parent/child paths in a single request', async () => {
const actor = makeActor(20);
const key = 'overlap-paths';
await su.sudo(actor, () => kvStore.set({
key,
value: { a: { b: { c: 1 } } },
}));
await expect(su.sudo(actor, () => kvStore.incr({
key,
pathAndAmountMap: { 'a.b': 1, 'a.b.c': 1 },
}))).rejects.toThrow(/paths overlap/i);
await expect(su.sudo(actor, () => kvStore.add({
key,
pathAndValueMap: { 'a.b': 1, 'a.b.c': 2 },
}))).rejects.toThrow(/paths overlap/i);
await expect(su.sudo(actor, () => kvStore.update({
key,
pathAndValueMap: { 'a.b': 1, 'a.b.c': 2 },
}))).rejects.toThrow(/paths overlap/i);
await expect(su.sudo(actor, () => kvStore.remove({
key,
paths: ['a.b', 'a.b.c'],
}))).resolves.not.toThrow();
});
it('incr initializes nested maps for missing keys', async () => {
const actor = makeActor(14);
const key = 'incr-missing';
const first = await su.sudo(actor, () => kvStore.incr({
key,
pathAndAmountMap: { 'a.b.c': 2, 'x': 1 },
}));
expect(first).toMatchObject({ a: { b: { c: 2 } }, x: 1 });
const second = await su.sudo(actor, () => kvStore.incr({
key,
pathAndAmountMap: { 'a.b.c': 3 },
}));
expect(second).toMatchObject({ a: { b: { c: 5 } }, x: 1 });
});
it('supports list index paths when incrementing', async () => {
const actor = makeActor(16);
const key = 'incr-list-index';
await su.sudo(actor, () => kvStore.set({
key,
value: { a: { b: [1, 2] } },
}));
const updated = await su.sudo(actor, () => kvStore.incr({
key,
pathAndAmountMap: { 'a.b[1]': 3 },
}));
expect((updated as { a?: { b?: number[] } }).a?.b).toEqual([1, 5]);
const stored = await su.sudo(actor, () => kvStore.get({ key }));
expect((stored as { a?: { b?: number[] } }).a?.b).toEqual([1, 5]);
});
it('enforces key and value size limits', async () => {
const actor = makeActor(11);
const oversizedKey = 'a'.repeat(((config as unknown as Record).kv_max_key_size as number) + 1);
const oversizedValue = 'b'.repeat(((config as unknown as Record).kv_max_value_size as number) + 1);
await expect(su.sudo(actor, () => kvStore.set({ key: oversizedKey, value: 'x' })))
.rejects
.toThrow(/1024/i);
await expect(su.sudo(actor, () => kvStore.set({ key: 'ok', value: oversizedValue })))
.rejects
.toThrow(/has exceeded the maximum allowed size/i);
});
});
================================================
FILE: src/backend/src/services/DynamoKVStore/DynamoKVStore.ts
================================================
import { Actor, SystemActorType } from '@heyputer/backend/src/services/auth/Actor.js';
import type { BaseDatabaseAccessService } from '@heyputer/backend/src/services/database/BaseDatabaseAccessService.js';
import type { MeteringService } from '@heyputer/backend/src/services/MeteringService/MeteringService.js';
import { RecursiveRecord } from '@heyputer/backend/src/services/MeteringService/types.js';
import { Context } from '@heyputer/backend/src/util/context.js';
import murmurhash from 'murmurhash';
import type { DDBClient } from '../../clients/dynamodb/DDBClient.js';
import { PUTER_KV_STORE_TABLE_DEFINITION } from './tableDefinition.js';
import { Span } from '../../util/otelutil.js';
import APIError from '../../api/APIError.js';
export class DynamoKVStore {
static GLOBAL_APP_KEY = 'os-global';
static LEGACY_GLOBAL_APP_KEY = 'global';
#ddbClient: DDBClient;
#sqlClient: BaseDatabaseAccessService;
#meteringService: MeteringService;
#tableName = 'store-kv-v1';
#pathCleanerRegex = /[:\-+/*]/g;
#enableMigrationFromSQL = false;
constructor ({ ddbClient, sqlClient, tableName, meteringService }: { ddbClient: DDBClient, sqlClient: BaseDatabaseAccessService, tableName: string, meteringService: MeteringService }) {
this.#ddbClient = ddbClient;
this.#sqlClient = sqlClient;
this.#tableName = tableName;
this.#meteringService = meteringService;
this.#enableMigrationFromSQL = !this.#ddbClient.config?.aws; // TODO: disable via config after some time passes
}
async createTableIfNotExists () {
if ( ! this.#enableMigrationFromSQL ) return;
await this.#ddbClient.createTableIfNotExists({ ...PUTER_KV_STORE_TABLE_DEFINITION, TableName: this.#tableName }, 'ttl');
}
#getNameSpace (actor: Actor) {
if ( actor.type instanceof SystemActorType ) {
return 'v1:system';
} else {
const app = actor.type?.app ?? undefined;
const user = actor.type?.user ?? undefined;
if ( ! user ) throw new Error('User not found');
return `v1:${app ? `${user.uuid}:${app.uid}`
: `${user.uuid}:${this.#enableMigrationFromSQL ? DynamoKVStore.LEGACY_GLOBAL_APP_KEY : DynamoKVStore.GLOBAL_APP_KEY}`}`;
}
}
@Span('kv:get')
async get ({ key }: { key: string | string[]; }): Promise {
if ( key === '' ) {
throw APIError.create('field_empty', null, {
key: 'key',
});
}
const actor = Context.get('actor');
const app = actor.type?.app ?? undefined;
const user = actor.type?.user ?? undefined;
const namespace = this.#getNameSpace(actor);
const multi = Array.isArray(key);
const keys = multi ? key : [key];
const values: unknown[] = [];
let kvEntries;
let usage;
if ( multi ) {
const entriesAndUsage = (await this.#getBatches(namespace, keys));
kvEntries = entriesAndUsage.kvEntries;
usage = entriesAndUsage.usage;
} else {
const res = await this.#ddbClient.get(this.#tableName, { namespace, key });
kvEntries = res.Item ? [res.Item] : [];
usage = res.ConsumedCapacity?.CapacityUnits ?? 0;
}
this.#meteringService.incrementUsage(actor, 'kv:read', usage || 0);
for ( const key of keys ) {
const kv_entry = kvEntries?.find(e => e.key === key);
const time = Date.now() / 1000;
if ( kv_entry?.ttl && kv_entry.ttl <= (time) ) {
values.push(null);
continue;
}
if ( kv_entry?.value ) {
values.push(kv_entry.value);
continue;
}
if ( this.#enableMigrationFromSQL ) {
const key_hash = murmurhash.v3(key);
const kv_row = await this.#sqlClient.read('SELECT * FROM kv WHERE user_id=? AND app=? AND kkey_hash=? LIMIT 1',
[user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY, key_hash]);
if ( kv_row[0]?.value ) {
// update and delete from this table
(async () => {
await this.set({ key: kv_row[0].key, value: kv_row[0].value });
await this.#sqlClient.write('DELETE FROM kv WHERE user_id=? AND app=? AND kkey_hash=?',
[user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY, key_hash]);
})();
values.push(kv_row[0]?.value);
continue;
}
}
values.push(kv_entry?.value ?? null);
}
return multi ? values : values[0];
}
/**
*
* @param {string} namespace
* @param {string[]} allKeys
* @returns
*/
async #getBatches (namespace: string, allKeys: string[]) {
const batches: string[][] = [];
for ( let i = 0; i < allKeys.length; i += 100 ) {
batches.push(allKeys.slice(i, i + 100));
}
const batchPromises = batches.map(async (keys) => {
const requests = [...new Set(keys)].map(k => ({ table: this.#tableName, items: { namespace, key: k } }));
const res = await this.#ddbClient.batchGet(requests);
const kvEntries = res.Responses?.[this.#tableName];
const usage = res.ConsumedCapacity?.reduce((acc, curr) => acc + (curr.CapacityUnits ?? 0), 0);
return { kvEntries, usage };
});
const batchGets = await Promise.all(batchPromises);
return batchGets.reduce((acc, curr) => {
acc.kvEntries!.push(...curr?.kvEntries ?? []);
acc.usage! += curr.usage || 0;
return acc;
}, { kvEntries: [], usage: 0 });
}
@Span('kv:set')
async set ({ key, value, expireAt }: { key: string; value: unknown; expireAt?: number; }): Promise {
const context = Context.get();
const actor = context.get('actor');
if ( key === '' ) {
throw APIError.create('field_empty', undefined, {
key: 'key',
});
}
key = String(key);
if ( Buffer.byteLength(key, 'utf8') > 1024 ) {
throw new Error(`key is too large. Max size is ${1024}.`);
}
if ( this.#enableMigrationFromSQL ) {
this.get({ key });
}
const namespace = this.#getNameSpace(actor);
const res = await this.#ddbClient.put(this.#tableName, {
namespace,
key,
value,
ttl: expireAt,
});
this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1);
return true;
}
@Span('kv:del')
async del ({ key }: { key: string; }): Promise {
const actor = Context.get('actor');
const app = actor.type?.app ?? undefined;
const user = actor.type?.user ?? undefined;
if ( ! user ) throw new Error('User not found');
const namespace = this.#getNameSpace(actor);
const res = await this.#ddbClient.del(this.#tableName, {
namespace,
key,
});
this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1);
if ( this.#enableMigrationFromSQL ) {
const key_hash = murmurhash.v3(key);
await this.#sqlClient.write('DELETE FROM kv WHERE user_id=? AND app=? AND kkey_hash=?',
[user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY, key_hash]);
}
return true;
}
#encodeCursor (pageKey?: Record) {
if ( !pageKey || Object.keys(pageKey).length === 0 ) {
return undefined;
}
return Buffer.from(JSON.stringify(pageKey)).toString('base64');
}
#decodeCursor (cursor?: string | Record) {
if ( ! cursor ) {
return undefined;
}
if ( typeof cursor === 'object' ) {
return cursor;
}
if ( typeof cursor !== 'string' ) {
throw APIError.create('field_invalid', undefined, {
key: 'cursor',
});
}
const trimmed = cursor.trim();
if ( trimmed === '' ) {
return undefined;
}
try {
const decoded = Buffer.from(trimmed, 'base64').toString('utf8');
return JSON.parse(decoded);
} catch ( e ) {
try {
return JSON.parse(trimmed);
} catch ( err ) {
throw APIError.create('field_invalid', undefined, {
key: 'cursor',
});
}
}
}
#normalizeLimit (limit?: number) {
if ( limit === undefined || limit === null ) {
return undefined;
}
const parsed = Number(limit);
if ( !Number.isFinite(parsed) || parsed <= 0 ) {
throw APIError.create('field_invalid', undefined, {
key: 'limit',
expected: 'positive number',
});
}
return Math.floor(parsed);
}
#normalizePattern (pattern?: string) {
if ( pattern === undefined || pattern === null ) {
return undefined;
}
if ( typeof pattern !== 'string' ) {
throw APIError.create('field_invalid', undefined, {
key: 'pattern',
});
}
const trimmed = pattern.trim();
if ( trimmed === '' ) {
return undefined;
}
if ( trimmed.endsWith('*') ) {
const prefix = trimmed.slice(0, -1);
return prefix === '' ? undefined : prefix;
}
return trimmed;
}
@Span('kv:list')
async list ({
as,
limit,
cursor,
pattern,
}: {
as?: 'keys' | 'values' | 'entries';
limit?: number;
cursor?: string | Record;
pattern?: string;
}): Promise<
| string[]
| unknown[]
| { key: string; value: unknown; }[]
| { items: string[]; cursor?: string; }
| { items: unknown[]; cursor?: string; }
| { items: { key: string; value: unknown; }[]; cursor?: string; }
> {
const actor = Context.get('actor');
const app = actor.type?.app ?? undefined;
const user = actor.type?.user ?? undefined;
if ( ! user ) throw new Error('User not found');
const namespace = this.#getNameSpace(actor);
const normalizedLimit = this.#normalizeLimit(limit);
const pageKey = this.#decodeCursor(cursor);
const normalizedPattern = this.#normalizePattern(pattern);
const paginated = normalizedLimit !== undefined || pageKey !== undefined;
const entriesRes = await this.#ddbClient.query(this.#tableName,
{ namespace },
normalizedLimit ?? 0,
pageKey,
'',
false,
normalizedPattern ? { beginsWith: { key: 'key', value: normalizedPattern } } : undefined);
this.#meteringService.incrementUsage(actor, 'kv:read', entriesRes.ConsumedCapacity?.CapacityUnits ?? 1);
let entries = entriesRes.Items ?? [];
entries = entries?.filter(entry => {
if ( ! entry ) {
return false;
}
if ( entry.ttl && entry.ttl <= (Date.now() / 1000) ) {
return false;
}
return true;
});
if ( this.#enableMigrationFromSQL && !paginated ) {
const oldEntries = await this.#sqlClient.read('SELECT * FROM kv WHERE user_id=? AND app=?',
[user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY]);
oldEntries.forEach(oldEntry => {
if ( normalizedPattern && !oldEntry.kkey?.startsWith(normalizedPattern) ) {
return;
}
if ( ! entries.find(e => e.key === oldEntry.kkey) ) {
if ( oldEntry.ttl && oldEntry.ttl <= (Date.now() / 1000) ) {
entries.push({ key: oldEntry.kkey, value: oldEntry.value });
}
}
});
}
entries = entries?.map(entry => ({
key: entry.key,
value: entry.value,
}));
as = as || 'entries';
if ( ! ['keys', 'values', 'entries'].includes(as) ) {
throw APIError.create('field_invalid', undefined, {
key: 'as',
expected: '"keys", "values", or "entries"',
});
}
let items: string[] | unknown[] | { key: string; value: unknown; }[] = entries;
if ( as === 'keys' ) items = entries.map(entry => entry.key);
else if ( as === 'values' ) items = entries.map(entry => entry.value);
if ( paginated ) {
const nextCursor = this.#encodeCursor(entriesRes.LastEvaluatedKey as Record | undefined);
if ( nextCursor ) {
return { items, cursor: nextCursor };
}
return { items };
}
return items;
}
@Span('kv:flush')
async flush () {
const actor = Context.get('actor');
const app = actor.type.app ?? undefined;
const user = actor.type?.user ?? undefined;
if ( ! user ) throw new Error('User not found');
const namespace = this.#getNameSpace(actor);
// Query all keys
const entriesRes = await this.#ddbClient.query(this.#tableName,
{ namespace });
const entries = entriesRes.Items ?? [];
const readUsage = entriesRes?.ConsumedCapacity?.CapacityUnits ?? 0;
// meter usage
this.#meteringService.incrementUsage(actor, 'kv:read', readUsage);
// TODO DS: implement batch delete so its faster and less demanding on server
const allRes = (await Promise.all(entries.map(entry => {
try {
return this.#ddbClient.del(this.#tableName, {
namespace,
key: entry.key,
});
} catch ( e ) {
console.error('Error deleting key', entry.key, e);
}
}))).filter(Boolean);
const writeUsage = allRes.reduce((acc, curr) => acc + (curr?.ConsumedCapacity?.CapacityUnits ?? 0), 0);
// meter usage
this.#meteringService.incrementUsage(actor, 'kv:write', writeUsage);
if ( this.#enableMigrationFromSQL ) {
await this.#sqlClient.write('DELETE FROM kv WHERE user_id=? AND app=?',
[user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY]);
}
return !!allRes;
}
@Span('kv:expireAt')
async expireAt ({ key, timestamp }: { key: string; timestamp: number; }): Promise {
if ( key === '' ) {
throw APIError.create('field_empty', null, {
key: 'key',
});
}
timestamp = Number(timestamp);
return await this.#expireAt(key, timestamp);
}
@Span('kv:expire')
async expire ({ key, ttl }: { key: string; ttl: number; }): Promise {
if ( key === '' ) {
throw APIError.create('field_empty', null, {
key: 'key',
});
}
ttl = Number(ttl);
// timestamp in seconds
let timestamp = Math.floor(Date.now() / 1000) + ttl;
return await this.#expireAt(key, timestamp);
}
async #createPaths ( namespace: string, key: string, pathList: string[]) {
const nestedMapValue = (() => {
const valueRoot: Record = {};
let hasPaths = false;
pathList.forEach((valPath) => {
if ( ! valPath ) return;
hasPaths = true;
const chunks = valPath.split('.').filter(Boolean);
let cursor: Record = valueRoot;
for ( let i = 0; i < chunks.length - 1; i++ ) {
const chunk = chunks[i];
const existing = cursor[chunk];
if ( !existing || typeof existing !== 'object' || Array.isArray(existing) ) {
cursor[chunk] = {};
}
cursor = cursor[chunk] as Record;
}
});
return hasPaths ? valueRoot : null;
})();
if ( ! nestedMapValue ) {
return 0;
}
const isPlainObject = (value: unknown): value is Record => {
return !!value && typeof value === 'object' && !Array.isArray(value);
};
const objectsEqual = (left: unknown, right: unknown): boolean => {
if ( left === right ) return true;
if ( !isPlainObject(left) || !isPlainObject(right) ) return false;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if ( leftKeys.length !== rightKeys.length ) return false;
for ( const key of leftKeys ) {
if ( ! rightKeys.includes(key) ) return false;
if ( ! objectsEqual(left[key], right[key]) ) return false;
}
return true;
};
// Collect all intermediate map paths for all entries
const allIntermediatePaths = new Set();
pathList.forEach((valPath) => {
const chunks = ['value', ...valPath.split('.')].filter(Boolean);
// For each intermediate map (excluding the leaf)
for ( let i = 1; i < chunks.length; i++ ) {
const subPath = chunks.slice(0, i).join('.');
allIntermediatePaths.add(subPath);
}
});
let writeUnits = 0;
// Ensure each intermediate map layer exists by issuing a separate DynamoDB update for each
const orderedPaths = [...allIntermediatePaths]
.sort((left, right) => left.split('.').length - right.split('.').length);
for ( const layerPath of orderedPaths ) {
// Build attribute names for the layer
const chunks = layerPath.split('.');
const attrName = chunks.map((chunk) => `#${chunk}`.replaceAll(this.#pathCleanerRegex, '')).join('.');
const expressionNames: Record = {};
chunks.forEach((chunk) => {
const cleanedChunk = chunk.split(/\[\d*\]/g)[0];
expressionNames[`#${cleanedChunk}`.replaceAll(this.#pathCleanerRegex, '')] = cleanedChunk;
});
const isRootLayer = layerPath === 'value';
const expressionValues = isRootLayer
? { ':nestedMap': nestedMapValue }
: { ':emptyMap': {} };
const valueToken = isRootLayer ? ':nestedMap' : ':emptyMap';
// Issue update to set layer to {} if not exists
const layerUpsertRes = await this.#ddbClient.update(this.#tableName,
{ key, namespace },
`SET ${attrName} = if_not_exists(${attrName}, ${valueToken})`,
expressionValues,
expressionNames);
writeUnits += layerUpsertRes.ConsumedCapacity?.CapacityUnits ?? 0;
if ( isRootLayer && objectsEqual(layerUpsertRes.Attributes?.value, nestedMapValue) ) {
return writeUnits;
}
}
return writeUnits;
}
// Ideally the paths support syntax like "a.b[2].c"
@Span('kv:incr')
async incr>({ key, pathAndAmountMap }: { key: string; pathAndAmountMap: T; }): Promise