feat: cleanup cache invalidation and pull out keys to be easily used in other places that need the same cache (#2515)

fix: tests
This commit is contained in:
Daniel Salazar
2026-02-19 15:07:04 -08:00
committed by GitHub
parent 78a4ccb9a4
commit ec412eaff6
59 changed files with 7184 additions and 3855 deletions
-6
View File
@@ -39,12 +39,6 @@ src/emulator/release/
# JS language server, ref: https://code.visualstudio.com/docs/languages/jsconfig
jsconfig.json
# ======================================================================
# node js
# ======================================================================
# the exact tree installed in the node_modules folder
package-lock.json
# ======================================================================
# playwright test (currently only test the file-system)
# ======================================================================
+6266 -3416
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -18,8 +18,8 @@
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1",
"@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18",
"chalk": "^4.1.0",
"clean-css": "^5.3.2",
"dotenv": "^16.4.5",
@@ -37,7 +37,7 @@
"typescript": "^5.4.5",
"uglify-js": "^3.17.4",
"vite-plugin-static-copy": "^3.1.3",
"vitest": "^4.0.14",
"vitest": "^4.0.18",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.1",
"yaml": "^2.8.1"
+1 -1
View File
@@ -79,7 +79,7 @@ const install = async ({ context, services, app, useapi, modapi }) => {
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.validation', require('./validation').default);
def('core.database', require('./services/database/consts.js'));
+1 -3
View File
@@ -25,11 +25,10 @@ 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');
@@ -196,7 +195,6 @@ class Kernel extends AdvancedBase {
services.ready.resolve();
// provide services to helpers
const { tmp_provide_services } = require('./helpers');
tmp_provide_services(services);
}
+180 -216
View File
@@ -16,31 +16,35 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const _path = require('path');
const micromatch = require('micromatch');
const config = require('./config');
const mime = require('mime-types');
const { LRUCache } = require('lru-cache');
const { ManagedError } = require('./util/errorutil.js');
const { spanify } = require('./util/otelutil.js');
const APIError = require('./api/APIError.js');
const { DB_READ, DB_WRITE } = require('./services/database/consts.js');
const { Context } = require('./util/context');
const { NodeUIDSelector } = require('./filesystem/node/selectors');
const { redisClient } = require('./clients/redis/redisSingleton');
const { kv } = require('./util/kvSingleton');
const { APP_ICONS_SUBDOMAIN } = require('./consts/app-icons.js');
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 { 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 { kv } from './util/kvSingleton.js';
import { spanify } from './util/otelutil.js';
const identifying_uuid = require('uuid').v4();
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 _servicesHolder = globalThis[SERVICES_KEY];
const servicesContainer = globalThis[SERVICES_KEY];
const tmp_provide_services = async ss => {
_servicesHolder.services = ss;
await _servicesHolder.services.ready;
export async function tmp_provide_services (ss) {
servicesContainer.services = ss;
await servicesContainer.services.ready;
};
// TTL for pending get_app queries (request coalescing)
@@ -62,11 +66,11 @@ const buildAppIconUrl = (app_uid, size = DEFAULT_APP_ICON_SIZE) => {
if ( ! app_uid ) return null;
const uid_string = String(app_uid);
const normalized_uid = uid_string.startsWith('app-') ? uid_string : `app-${uid_string}`;
const icon_size = Number.isFinite(Number(size)) ? Number(size) : DEFAULT_APP_ICON_SIZE;
const 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}-${icon_size}.png`;
return `${protocol}://${APP_ICONS_SUBDOMAIN}.${static_hosting_domain}/${normalized_uid}-${iconSize}.png`;
};
const withAppIconUrl = (app) => {
@@ -76,9 +80,9 @@ const withAppIconUrl = (app) => {
return { ...app, icon: icon_url };
};
async function is_empty (dir_uuid) {
export async function is_empty (dir_uuid) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
const db = servicesContainer.services.get('database').get(DB_READ, 'filesystem');
let rows;
@@ -104,8 +108,8 @@ async function is_empty (dir_uuid) {
* Checks to see if temp_users is disabled and return a boolean
* @returns {boolean}
*/
async function is_temp_users_disabled () {
const svc_feature_flag = await _servicesHolder.services.get('feature-flag');
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');
}
@@ -113,12 +117,12 @@ async function is_temp_users_disabled () {
* Checks to see if user_signup is disabled and return a boolean
* @returns {boolean}
*/
async function is_user_signup_disabled () {
const svc_feature_flag = await _servicesHolder.services.get('feature-flag');
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');
}
const chkperm = spanify('chkperm', async (target_fsentry, requester_user_id, action) => {
export const chkperm = spanify('chkperm', async (target_fsentry, requester_user_id, action) => {
// basic cases where false is the default response
if ( ! target_fsentry )
{
@@ -151,7 +155,7 @@ const chkperm = spanify('chkperm', async (target_fsentry, requester_user_id, act
* @param {string} name
* @returns
*/
function validate_fsentry_name (name) {
export function validate_fsentry_name (name) {
if ( ! name )
{
throw { message: 'Name can not be empty.' };
@@ -188,9 +192,9 @@ function validate_fsentry_name (name) {
* @param {integer} id - `id` of FSEntry
* @returns {Promise} Promise object represents the UUID of the FileSystem Entry
*/
async function id2uuid (id) {
export async function id2uuid (id) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
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]);
@@ -210,9 +214,9 @@ async function id2uuid (id) {
* @param {integer} user_id - `user_id` of user
* @returns {Promise} Promise object represents the UUID of the FileSystem Entry
*/
async function df (user_id) {
export async function df (user_id) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
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 )
@@ -234,8 +238,8 @@ async function df (user_id) {
* @param {string} options - `options`
* @returns {Promise}
*/
async function get_user (options) {
return await _servicesHolder.services.get('get-user').get_user(options);
export async function get_user (options) {
return await servicesContainer.services.get('get-user').get_user(options);
}
/**
@@ -243,28 +247,21 @@ async function get_user (options) {
*
* @param {User} userID - the user entry to invalidate
*/
const invalidate_cached_user = async (user) => {
await Promise.all([
redisClient.del(`users:username:${ user.username}`),
redisClient.del(`users:uuid:${ user.uuid}`),
redisClient.del(`users:email:${ user.email}`),
redisClient.del(`users:id:${ user.id}`),
]);
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
*/
const invalidate_cached_user_by_id = async (id) => {
const user = safe_json_parse(await redisClient.get(`users:id:${ id}`), null);
if ( ! user ) return;
invalidate_cached_user(user);
export const invalidate_cached_user_by_id = async (id) => {
await UserRedisCacheSpace.invalidateById(id);
};
async function refresh_associations_cache () {
export async function refresh_associations_cache () {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'apps');
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 = {};
@@ -279,7 +276,7 @@ async function refresh_associations_cache () {
}
for ( const k in lists ) {
await redisClient.set(`assocs:${k}:apps`, JSON.stringify(lists[k]));
await redisClient.set(AppRedisCacheSpace.associationAppsKey(k), JSON.stringify(lists[k]));
}
}
@@ -289,19 +286,19 @@ async function refresh_associations_cache () {
* @param {string} options - `options`
* @returns {Promise}
*/
async function get_app (options) {
export async function get_app (options) {
const cacheApp = async (app) => {
if ( ! app ) return;
app = JSON.stringify(app);
await redisClient.set(`apps:uid:${app.uid}`, app, 'EX', 30);
await redisClient.set(`apps:name:${app.name}`, app, 'EX', 30);
await redisClient.set(`apps:id:${app.id}`, app, 'EX', 30);
await AppRedisCacheSpace.setCachedApp(app, {
rawIcon: true,
ttlSeconds: 30,
});
};
// This condition should be updated if the code below is re-ordered.
if ( options.follow_old_names && !options.uid && options.name ) {
const svc_oldAppName = _servicesHolder.services.get('old-app-name');
const 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;
@@ -317,13 +314,25 @@ async function get_app (options) {
let cacheKey;
if ( options.uid ) {
queryKey = `uid:${options.uid}`;
cacheKey = `apps:uid:${options.uid}`;
cacheKey = AppRedisCacheSpace.key({
lookup: 'uid',
value: options.uid,
rawIcon: true,
});
} else if ( options.name ) {
queryKey = `name:${options.name}`;
cacheKey = `apps:name:${options.name}`;
cacheKey = AppRedisCacheSpace.key({
lookup: 'name',
value: options.name,
rawIcon: true,
});
} else if ( options.id ) {
queryKey = `id:${options.id}`;
cacheKey = `apps:id:${options.id}`;
cacheKey = AppRedisCacheSpace.key({
lookup: 'id',
value: options.id,
rawIcon: true,
});
} else {
// No valid lookup parameter
return null;
@@ -338,7 +347,14 @@ async function get_app (options) {
}
// Check if there's already a pending query for this key (request coalescing)
const pendingKey = `pending_app:${queryKey}`;
const separatorIndex = queryKey.indexOf(':');
const pendingLookup = queryKey.slice(0, separatorIndex);
const pendingValue = queryKey.slice(separatorIndex + 1);
const pendingKey = AppRedisCacheSpace.pendingKey({
lookup: pendingLookup,
value: pendingValue,
rawIcon: true,
});
const pending = kv.get(pendingKey);
if ( pending ) {
// Reuse the existing pending query
@@ -359,7 +375,7 @@ async function get_app (options) {
try {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'apps');
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];
@@ -395,49 +411,25 @@ async function get_app (options) {
* @param {boolean} [options.rawIcon] - When true, include raw icon data.
* @returns {Promise<Array<object|null>>}
*/
const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
export const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
if ( ! Array.isArray(specifiers) ) {
specifiers = [specifiers];
}
const rawIcon = Boolean(options.rawIcon);
const cacheNamespace = rawIcon ? 'apps' : 'apps:lite';
const pendingNamespace = rawIcon ? 'pending_app' : 'pending_app_lite';
const decorateApp = (app) => (rawIcon ? app : withAppIconUrl(app));
const cacheApp = async (app) => {
if ( ! app ) return;
const cached_app = JSON.stringify(app);
await redisClient.set(`${cacheNamespace}:uid:${cached_app.uid}`, cached_app, 'EX', 60);
await redisClient.set(`${cacheNamespace}:name:${cached_app.name}`, cached_app, 'EX', 60);
await redisClient.set(`${cacheNamespace}:id:${cached_app.id}`, cached_app, 'EX', 60);
await AppRedisCacheSpace.setCachedApp(app, {
rawIcon: rawIcon,
ttlSeconds: 60,
});
};
const APP_COLUMNS_NO_ICON = [
'id',
'uid',
'owner_user_id',
'name',
'title',
'description',
'godmode',
'maximize_on_start',
'index_url',
'approved_for_listing',
'approved_for_opening_items',
'approved_for_incentive_program',
'timestamp',
'last_review',
'tags',
'app_owner',
'metadata',
'protected',
'background',
].map(column => `\`${column}\``).join(', ');
const normalized = specifiers.map(spec => spec ? { ...spec } : {});
if ( options.follow_old_names ) {
const svc_oldAppName = _servicesHolder.services.get('old-app-name');
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);
@@ -471,7 +463,14 @@ const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
return;
}
const pendingKey = `${pendingNamespace}:${queryKey}`;
const separatorIndex = queryKey.indexOf(':');
const lookup = queryKey.slice(0, separatorIndex);
value = queryKey.slice(separatorIndex + 1);
const pendingKey = AppRedisCacheSpace.pendingKey({
lookup,
value,
rawIcon: rawIcon,
});
const pending = kv.get(pendingKey);
if ( pending ) {
pendingLookups.set(queryKey, pending);
@@ -498,7 +497,11 @@ const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
for ( const spec of normalized ) {
if ( spec.uid ) {
const cached = safe_json_parse(await redisClient.get(`${cacheNamespace}:uid:${spec.uid}`), null);
const cached = await AppRedisCacheSpace.getCachedApp({
lookup: 'uid',
value: spec.uid,
rawIcon: rawIcon,
});
if ( cached ) {
addApp(decorateApp(cached));
} else {
@@ -507,7 +510,11 @@ const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
continue;
}
if ( spec.name ) {
const cached = safe_json_parse(await redisClient.get(`${cacheNamespace}:name:${spec.name}`), null);
const cached = await AppRedisCacheSpace.getCachedApp({
lookup: 'name',
value: spec.name,
rawIcon: rawIcon,
});
if ( cached ) {
addApp(decorateApp(cached));
} else {
@@ -516,7 +523,11 @@ const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
continue;
}
if ( spec.id ) {
const cached = safe_json_parse(await redisClient.get(`${cacheNamespace}:id:${spec.id}`), null);
const cached = await AppRedisCacheSpace.getCachedApp({
lookup: 'id',
value: spec.id,
rawIcon: rawIcon,
});
if ( cached ) {
addApp(decorateApp(cached));
} else {
@@ -531,7 +542,7 @@ const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
if ( queryUids.size || queryNames.size || queryIds.size ) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'apps');
const db = servicesContainer.services.get('database').get(DB_READ, 'apps');
const clauses = [];
const params = [];
@@ -555,8 +566,7 @@ const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
let rows = [];
const resolvedKeys = new Set();
try {
const select_columns = rawIcon ? '*' : APP_COLUMNS_NO_ICON;
rows = await db.read(`SELECT ${select_columns} FROM \`apps\` WHERE ${clauses.join(' OR ')}`,
rows = await db.read(`SELECT * FROM \`apps\` WHERE ${clauses.join(' OR ')}`,
params);
for ( const app of rows ) {
const decorated_app = decorateApp(app);
@@ -593,7 +603,7 @@ const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
throw err;
} finally {
for ( const { pendingKey } of pendingToResolve.values() ) {
await redisClient.del(pendingKey);
kv.del(pendingKey);
}
}
@@ -624,9 +634,9 @@ const get_apps = spanify('get_apps', async (specifiers, options = {}) => {
* @param {string} options - `options`
* @returns {Promise}
*/
async function app_exists (options) {
export async function app_exists (options) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'apps');
const db = servicesContainer.services.get('database').get(DB_READ, 'apps');
let app;
if ( options.uid )
@@ -651,9 +661,9 @@ async function app_exists (options) {
* @param {string} options - `options`
* @returns {Promise}
*/
async function change_username (user_id, new_username) {
export async function change_username (user_id, new_username) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_WRITE, 'auth');
const db = servicesContainer.services.get('database').get(DB_WRITE, 'auth');
const old_username = (await get_user({ id: user_id })).username;
@@ -665,9 +675,9 @@ async function change_username (user_id, new_username) {
[new_username, `/${ new_username}`, user_id]);
console.log(`User ${old_username} changed username to ${new_username}`);
await _servicesHolder.services.get('filesystem').update_child_paths(`/${old_username}`, `/${new_username}`, user_id);
await servicesContainer.services.get('filesystem').update_child_paths(`/${old_username}`, `/${new_username}`, user_id);
invalidate_cached_user_by_id(user_id);
await invalidate_cached_user_by_id(user_id);
}
/**
@@ -677,9 +687,9 @@ async function change_username (user_id, new_username) {
* @returns {Promise} Promise object represents the UUID of the FileSystem Entry
* @deprecated Use fs middleware instead
*/
async function uuid2fsentry (uuid, return_thumbnail) {
export async function uuid2fsentry (uuid, return_thumbnail) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
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
@@ -725,9 +735,9 @@ async function uuid2fsentry (uuid, return_thumbnail) {
* @param {integer} id - `id` of FSEntry
* @returns {Promise} Promise object represents the UUID of the FileSystem Entry
*/
async function id2fsentry (id, return_thumbnail) {
export async function id2fsentry (id, return_thumbnail) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
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
@@ -771,7 +781,7 @@ async function id2fsentry (id, return_thumbnail) {
* @returns {false|object} - `false` if path could not be resolved, otherwise an object representing the FSEntry
* @deprecated Use fs middleware instead
*/
async function convert_path_to_fsentry (path) {
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
@@ -803,7 +813,7 @@ async function convert_path_to_fsentry (path) {
let result;
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
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',
@@ -848,7 +858,7 @@ async function convert_path_to_fsentry (path) {
* @param {integer} bytes - size in bytes
* @returns {string} bytes in human-readable format
*/
function byte_format (bytes) {
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 ) {
@@ -858,7 +868,7 @@ function byte_format (bytes) {
return `${Math.round(bytes / Math.pow(1024, i), 2) } ${ sizes[i]}`;
};
const get_descendants = spanify('get_descendants', async (...args) => {
export const get_descendants = spanify('get_descendants', async (...args) => {
return await getDescendantsHelper(...args);
});
@@ -867,17 +877,17 @@ const get_descendants = spanify('get_descendants', async (...args) => {
* @param {integer} entry_id
* @returns
*/
const id2path = spanify('helpers:id2path', async (entry_uid) => {
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 = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
const db = servicesContainer.services.get('database').get(DB_READ, 'filesystem');
const log = _servicesHolder.services.get('log-service').create('helpers.id2path');
const log = servicesContainer.services.get('log-service').create('helpers.id2path');
log.traceOn();
const errors = _servicesHolder.services.get('error-service').create(log);
const errors = servicesContainer.services.get('error-service').create(log);
log.called();
let result;
@@ -947,13 +957,13 @@ const id2path = spanify('helpers:id2path', async (entry_uid) => {
* @returns
*/
async function getDescendantsHelper (path, user, depth, return_thumbnail = false) {
const log = _servicesHolder.services.get('log-service').create('get_descendants');
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 = _path.resolve('/', path);
path = _resolve('/', path);
// get parent dir
const parent = await convert_path_to_fsentry(path);
// holds array that will be returned
@@ -970,7 +980,7 @@ async function getDescendantsHelper (path, user, depth, return_thumbnail = false
}
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
const db = servicesContainer.services.get('database').get(DB_READ, 'filesystem');
// -------------------------------------
// parent is root ('/')
@@ -1081,7 +1091,7 @@ async function getDescendantsHelper (path, user, depth, return_thumbnail = false
for ( const row of rows ) websiteMap[row.root_dir_id] = true;
for ( let i = 0; i < children.length; i++ ) {
const contentType = mime.contentType(children[i].name);
const contentType = _contentType(children[i].name);
// has_website
let has_website = false;
@@ -1124,7 +1134,7 @@ async function getDescendantsHelper (path, user, depth, return_thumbnail = false
return ret.flat();
};
const get_dir_size = async (path, user) => {
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++ ) {
@@ -1142,9 +1152,9 @@ const get_dir_size = async (path, user) => {
* @param {object} user
* @returns
*/
async function resolve_glob (glob, user) {
export async function resolve_glob (glob, user) {
//turn glob into abs path
glob = _path.resolve('/', glob);
glob = _resolve('/', glob);
//get base of glob
const base = micromatch.scan(glob).base;
//estimate needed depth
@@ -1170,7 +1180,7 @@ function isString (variable) {
return typeof variable === 'string' || variable instanceof String;
}
const body_parser_error_handler = (err, req, res, next) => {
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
}
@@ -1200,7 +1210,7 @@ async function get_entry (uid) {
});
}
async function is_ancestor_of (ancestor_uid, descendant_uid) {
export async function is_ancestor_of (ancestor_uid, descendant_uid) {
const ancestor = await get_entry(ancestor_uid);
const descendant = await get_entry(descendant_uid);
@@ -1209,7 +1219,7 @@ async function is_ancestor_of (ancestor_uid, descendant_uid) {
}
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
const db = servicesContainer.services.get('database').get(DB_READ, 'filesystem');
// root is an ancestor to all FSEntries
if ( ancestor_uid === null )
@@ -1252,8 +1262,7 @@ async function is_ancestor_of (ancestor_uid, descendant_uid) {
return false;
}
async function sign_file (fsentry, action) {
const sha256 = require('js-sha256').sha256;
export async function sign_file (fsentry, action) {
// fsentry not found
if ( fsentry === false ) {
@@ -1265,7 +1274,7 @@ async function sign_file (fsentry, action) {
const secret = config.url_signature_secret;
const expires = Math.ceil(Date.now() / 1000) + ttl;
const signature = sha256(`${uid}/${action}/${secret}/${expires}`);
const contentType = mime.contentType(fsentry.name);
const contentType = _contentType(fsentry.name);
// return
return {
@@ -1286,8 +1295,7 @@ async function sign_file (fsentry, action) {
};
}
async function gen_public_token (file_uuid) {
const { v4: uuidv4 } = require('uuid');
export async function gen_public_token (file_uuid) {
// get fsentry
let fsentry = await uuid2fsentry(file_uuid);
@@ -1298,11 +1306,11 @@ async function gen_public_token (file_uuid) {
}
const uid = fsentry.uuid;
const token = uuidv4();
const contentType = mime.contentType(fsentry.name);
const token = v4();
const contentType = _contentType(fsentry.name);
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_WRITE, 'filesystem');
const db = servicesContainer.services.get('database').get(DB_WRITE, 'filesystem');
// insert into DB
try {
@@ -1329,10 +1337,10 @@ async function gen_public_token (file_uuid) {
};
}
async function deleteUser (user_id) {
export async function deleteUser (user_id) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
const svc_fs = _servicesHolder.services.get('filesystem');
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
@@ -1363,12 +1371,12 @@ async function deleteUser (user_id) {
await db.write('DELETE FROM user WHERE id = ?', [user_id]);
}
function subdomain (req) {
export function subdomain (req) {
if ( config.experimental_no_subdomain ) return 'api';
return req.hostname.slice(0, -1 * (config.domain.length + 1));
}
async function jwt_auth (req) {
export async function jwt_auth (req) {
let token;
// HTTML Auth header
if ( req.header && req.header('Authorization') )
@@ -1433,9 +1441,9 @@ async function jwt_auth (req) {
*
* @param {*} fsentry_id
*/
async function ancestors (fsentry_id) {
export async function ancestors (fsentry_id) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
const db = servicesContainer.services.get('database').get(DB_READ, 'filesystem');
const ancestors = [];
// first parent
@@ -1455,7 +1463,7 @@ async function ancestors (fsentry_id) {
return ancestors;
}
function hyphenize_confirm_code (email_confirm_code) {
export function hyphenize_confirm_code (email_confirm_code) {
email_confirm_code = email_confirm_code.toString();
email_confirm_code =
`${email_confirm_code[0] +
@@ -1468,9 +1476,9 @@ function hyphenize_confirm_code (email_confirm_code) {
return email_confirm_code;
}
async function username_exists (username) {
export async function username_exists (username) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
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 )
@@ -1479,9 +1487,9 @@ async function username_exists (username) {
}
}
async function app_name_exists (name) {
export async function app_name_exists (name) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
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 )
@@ -1489,25 +1497,25 @@ async function app_name_exists (name) {
return true;
}
const svc_oldAppName = _servicesHolder.services.get('old-app-name');
const svc_oldAppName = servicesContainer.services.get('old-app-name');
const name_info = await svc_oldAppName.check_app_name(name);
if ( name_info ) return true;
}
function send_email_verification_code (email_confirm_code, email) {
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),
});
}
function send_email_verification_token (email_confirm_token, email, user_uuid) {
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 });
}
function generate_random_str (length) {
export function generate_random_str (length) {
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const charactersLength = characters.length;
@@ -1525,7 +1533,7 @@ function generate_random_str (length) {
* @returns {string} The time represented in the format: 'X years Y days Z hours A minutes B seconds'.
* @throws {TypeError} If the `seconds` parameter is not a number.
*/
function seconds_to_string (seconds) {
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);
@@ -1602,7 +1610,7 @@ const SUGGEST_APP_CODE_EXTS = [
const buildSuggestedAppSpecifiers = async (fsentry) => {
const name_specifiers = [];
let content_type = mime.contentType(fsentry.name);
let content_type = _contentType(fsentry.name);
if ( ! content_type ) content_type = '';
// IIFE just so fsname can stay `const`
@@ -1615,7 +1623,7 @@ const buildSuggestedAppSpecifiers = async (fsentry) => {
if ( fsentry.is_dir ) fsname += '.directory';
return fsname;
})();
const file_extension = _path.extname(fsname).toLowerCase();
const file_extension = extname(fsname).toLowerCase();
const any_of = (list, name) => list.some(v => name.endsWith(v));
@@ -1690,7 +1698,9 @@ const buildSuggestedAppSpecifiers = async (fsentry) => {
//---------------------------------------------
// 3rd-party apps
//---------------------------------------------
const apps = safe_json_parse(await redisClient.get(`assocs:${file_extension.slice(1)}: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 }));
@@ -1744,7 +1754,7 @@ const cloneSuggestedApps = (suggested_apps) => (
: suggested_apps
);
async function suggestedAppsForFsEntries (fsentries, options) {
export async function suggestedAppsForFsEntries (fsentries, options) {
if ( ! Array.isArray(fsentries) ) {
fsentries = [fsentries];
}
@@ -1841,14 +1851,19 @@ async function suggestedAppsForFsEntries (fsentries, options) {
return deduplicatedResults;
}
async function suggestedAppForFsEntry (fsentry, options) {
export async function suggestedAppForFsEntry (fsentry, options) {
const [result] = await suggestedAppsForFsEntries([fsentry], options);
return result;
}
async function get_taskbar_items (user, { icon_size, no_icons } = {}) {
export async function get_taskbar_items (user, {
icon_size: iconSizeFromSnake,
iconSize: iconSizeFromCamel,
no_icons,
} = {}) {
const iconSize = iconSizeFromCamel ?? iconSizeFromSnake;
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_WRITE, 'filesystem');
const db = servicesContainer.services.get('database').get(DB_WRITE, 'filesystem');
let taskbar_items_from_db = [];
// If taskbar items don't exist (specifically NULL)
@@ -1862,12 +1877,12 @@ async function get_taskbar_items (user, { icon_size, no_icons } = {}) {
{ name: 'camera', type: 'app' },
{ name: 'recorder', type: 'app' },
];
db.write('UPDATE user SET taskbar_items = ? WHERE id = ?',
await db.write('UPDATE user SET taskbar_items = ? WHERE id = ?',
[
JSON.stringify(taskbar_items_from_db),
user.id,
]);
invalidate_cached_user(user);
await invalidate_cached_user(user);
}
// there are items from before
else {
@@ -1918,10 +1933,10 @@ async function get_taskbar_items (user, { icon_size, no_icons } = {}) {
if ( no_icons ) {
delete item.icon;
} else {
const svc_appIcon = _servicesHolder.services.get('app-icon');
const svc_appIcon = servicesContainer.services.get('app-icon');
const iconUrl = svc_appIcon.getSizedIconUrl({
appUid: item.uid,
size: icon_size,
size: iconSize,
});
item.icon = iconUrl;;
@@ -1934,7 +1949,7 @@ async function get_taskbar_items (user, { icon_size, no_icons } = {}) {
return taskbar_items;
}
function validate_signature_auth (url, action, options = {}) {
export function validate_signature_auth (url, action, options = {}) {
const query = new URL(url).searchParams;
if ( ! query.get('uid') )
@@ -1970,7 +1985,6 @@ function validate_signature_auth (url, action, options = {}) {
const uid = query.get('uid');
const secret = config.url_signature_secret;
const sha256 = require('js-sha256').sha256;
// before doing anything, see if this signature is valid for 'write' action, if yes that means every action is allowed
if ( !expired && query.get('signature') === sha256(`${uid}/write/${secret}/${query.get('expires')}`) )
@@ -1989,7 +2003,7 @@ function validate_signature_auth (url, action, options = {}) {
}
}
function get_url_from_req (req) {
export function get_url_from_req (req) {
return `${req.protocol }://${ req.get('host') }${req.originalUrl}`;
}
@@ -2003,7 +2017,7 @@ function get_url_from_req (req) {
* @returns {string} The formatted number with grouped thousands, using the specified decimal point and thousands separator characters.
* @throws {TypeError} If the `number` parameter cannot be converted to a finite number, or if the `decimals` parameter is non-finite and cannot be converted to an absolute number.
*/
function number_format (number, decimals, dec_point, thousands_sep) {
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,
@@ -2026,53 +2040,3 @@ function number_format (number, decimals, dec_point, thousands_sep) {
}
return s.join(dec);
}
module.exports = {
ancestors,
app_name_exists,
app_exists,
body_parser_error_handler,
byte_format,
change_username,
chkperm,
convert_path_to_fsentry,
deleteUser,
get_descendants,
get_dir_size,
gen_public_token,
get_taskbar_items,
get_url_from_req,
generate_random_str,
get_app,
get_apps,
get_user,
invalidate_cached_user,
invalidate_cached_user_by_id,
hyphenize_confirm_code,
id2fsentry,
id2path,
id2uuid,
is_ancestor_of,
is_empty,
...require('./validation'),
is_temp_users_disabled,
is_user_signup_disabled,
jwt_auth,
number_format,
refresh_associations_cache,
resolve_glob,
seconds_to_string,
send_email_verification_code,
send_email_verification_token,
sign_file,
subdomain,
suggestedAppsForFsEntries,
suggestedAppForFsEntry,
df,
username_exists,
uuid2fsentry,
validate_fsentry_name,
validate_signature_auth,
tmp_provide_services,
identifying_uuid,
};
@@ -20,6 +20,7 @@ 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 { AppRedisCacheSpace } = require('./AppRedisCacheSpace.js');
// Currently leaks memory (not sure why yet, but icons are a factor)
const ENABLE_REFRESH_APP_CACHE = false;
@@ -63,6 +64,22 @@ class AppInformationService extends BaseService {
}
'__on_boot.consolidation' () {
const svc_event = this.services.get('event');
svc_event.on('app.rename', async (_, { app_uid: appUid, old_name: oldName }) => {
try {
await 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', async (_, { app_uid: appUid, app }) => {
try {
await this.invalidateAppCache({ appUid, app });
} catch (e) {
this.log.error('failed invalidating app cache after app.changed', { appUid, error: e });
}
});
(async () => {
try {
ENABLE_REFRESH_APP_CACHE && await this._refresh_app_cache();
@@ -95,6 +112,53 @@ class AppInformationService extends BaseService {
})();
}
async invalidateAppCache ({ appUid, oldName, app }) {
let resolvedApp = app ?? null;
if ( !resolvedApp && appUid ) {
resolvedApp = await AppRedisCacheSpace.getCachedApp({
lookup: 'uid',
value: appUid,
rawIcon: true,
});
}
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([
redisClient.del(AppRedisCacheSpace.key({
lookup: 'uid',
value: appUid,
rawIcon: true,
})),
redisClient.del(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.
*
@@ -123,9 +187,9 @@ class AppInformationService extends BaseService {
// Check cache first if period is 'all' and no grouping is requested
if ( period === 'all' && !stats_grouping ) {
const key_open_count = `apps:open_count:uid:${app_uid}`;
const key_user_count = `apps:user_count:uid:${app_uid}`;
const key_referral_count = `apps:referral_count:uid:${app_uid}`;
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),
@@ -327,7 +391,7 @@ class AppInformationService extends BaseService {
user_count: completeUserStats,
},
referral_count: period === 'all'
? parse_cached_int(await redisClient.get(`apps:referral_count:uid:${app_uid}`))
? parse_cached_int(await redisClient.get(AppRedisCacheSpace.referralCountKey(app_uid)))
: null,
};
}
@@ -395,7 +459,7 @@ class AppInformationService extends BaseService {
user_count: completeUserStats,
},
referral_count: period === 'all'
? parse_cached_int(await redisClient.get(`apps:referral_count:uid:${app_uid}`))
? parse_cached_int(await redisClient.get(AppRedisCacheSpace.referralCountKey(app_uid)))
: null,
};
}
@@ -437,14 +501,14 @@ class AppInformationService extends BaseService {
open_count: parseInt(openRows[0].open_count),
user_count: parseInt(userRows[0].uniqueUsers),
referral_count: period === 'all'
? parse_cached_int(await redisClient.get(`apps:referral_count:uid:${app_uid}`))
? 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 = `apps:open_count:uid:${app_uid}`;
const key_user_count = `apps:user_count:uid:${app_uid}`;
const key_open_count = AppRedisCacheSpace.openCountKey(app_uid);
const key_user_count = AppRedisCacheSpace.userCountKey(app_uid);
await Promise.all([
redisClient.set(key_open_count, results.open_count),
redisClient.set(key_user_count, results.user_count),
@@ -475,14 +539,14 @@ class AppInformationService extends BaseService {
open_count: parseInt(openResult[0].open_count),
user_count: parseInt(userResult[0].user_count),
referral_count: period === 'all'
? parse_cached_int(await redisClient.get(`apps:referral_count:uid:${app_uid}`))
? 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 = `apps:open_count:uid:${app_uid}`;
const key_user_count = `apps:user_count:uid:${app_uid}`;
const key_open_count = AppRedisCacheSpace.openCountKey(app_uid);
const key_user_count = AppRedisCacheSpace.userCountKey(app_uid);
await Promise.all([
redisClient.set(key_open_count, results.open_count),
redisClient.set(key_user_count, results.user_count),
@@ -504,11 +568,9 @@ class AppInformationService extends BaseService {
let apps = await db.read('SELECT * FROM apps');
for ( const app of apps ) {
const cached_app = JSON.stringify(app);
await Promise.all([
redisClient.set(`apps:name:${ app.name}`, cached_app),
redisClient.set(`apps:id:${ app.id}`, cached_app),
redisClient.set(`apps:uid:${ app.uid}`, cached_app),
AppRedisCacheSpace.setCachedApp(app, { rawIcon: true }),
AppRedisCacheSpace.setCachedApp(app, { rawIcon: false }),
]);
}
}
@@ -549,8 +611,8 @@ class AppInformationService extends BaseService {
const apps = await db.read('SELECT uid FROM apps');
for ( const app of apps ) {
const key_open_count = `apps:open_count:uid:${app.uid}`;
const key_user_count = `apps:user_count:uid:${app.uid}`;
const key_open_count = AppRedisCacheSpace.openCountKey(app.uid);
const key_user_count = AppRedisCacheSpace.userCountKey(app.uid);
await Promise.all([
redisClient.set(key_open_count, openCountMap.get(app.uid) ?? 0),
@@ -633,7 +695,7 @@ class AppInformationService extends BaseService {
// Update cache with results
for ( const app of validApps ) {
const key_referral_count = `apps:referral_count:uid:${app.uid}`;
const key_referral_count = AppRedisCacheSpace.referralCountKey(app.uid);
const count = referralMap.get(app.uid) || 0;
await redisClient.set(key_referral_count, count);
}
@@ -658,7 +720,7 @@ class AppInformationService extends BaseService {
// Use SCAN to avoid KEYS, which is often disabled on managed/serverless Redis.
const [next_cursor, keys] = await redisClient.scan(cursor,
'MATCH',
'apps:uid:*',
AppRedisCacheSpace.uidScanPattern({ rawIcon: true }),
'COUNT',
1000);
cursor = next_cursor;
@@ -702,15 +764,11 @@ class AppInformationService extends BaseService {
const db = this.services.get('database').get(DB_READ, 'apps');
if ( ! app ) {
const cached_app = await redisClient.get(`apps:uid:${ app_uid}`);
if ( cached_app ) {
try {
app = JSON.parse(cached_app);
} catch (e) {
console.warn(e);
// no-op cache in invalid state
}
}
app = await AppRedisCacheSpace.getCachedApp({
lookup: 'uid',
value: app_uid,
rawIcon: true,
});
}
if ( ! app ) {
app = (await db.read('SELECT * FROM apps WHERE uid = ?',
@@ -721,15 +779,25 @@ class AppInformationService extends BaseService {
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]);
// remove from caches
await Promise.all([
redisClient.del(`apps:name:${ app.name}`),
redisClient.del(`apps:id:${ app.id}`),
redisClient.del(`apps:uid:${ app.uid}`),
]);
await 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 redisClient.del(...associationKeys);
}
// remove from recent
const index = this.collections.recent.indexOf(app_uid);
@@ -750,9 +818,10 @@ class AppInformationService extends BaseService {
}
const svc_event = this.services.get('event');
svc_event.emit('app.changed', {
await svc_event.emit('app.changed', {
app_uid: app.uid,
action: 'deleted',
app,
});
}
@@ -0,0 +1,120 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { redisClient } from '../../clients/redis/redisSingleton.js';
const appFullNamespace = 'apps';
const appLiteNamespace = 'apps:lite';
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 = ({ rawIcon = true } = {}) => (
rawIcon ? appFullNamespace : appLiteNamespace
);
const appCacheKey = ({ lookup, value, rawIcon = true }) => (
`${appNamespace({ rawIcon })}:${lookup}:${value}`
);
export const AppRedisCacheSpace = {
key: appCacheKey,
namespace: appNamespace,
keysForApp: (app, { rawIcon = true } = {}) => {
if ( ! app ) return [];
return appLookupKeys
.filter(lookup => app[lookup] !== undefined && app[lookup] !== null && app[lookup] !== '')
.map(lookup => appCacheKey({ lookup, value: app[lookup], rawIcon }));
},
uidScanPattern: ({ rawIcon = true } = {}) => `${appNamespace({ rawIcon })}:uid:*`,
pendingNamespace: ({ rawIcon = true } = {}) => rawIcon ? 'pending_app' : 'pending_app_lite',
pendingKey: ({ lookup, value, rawIcon = true }) => (
`${AppRedisCacheSpace.pendingNamespace({ rawIcon })}:${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, rawIcon = true }) => (
safeParseJson(await redisClient.get(appCacheKey({ lookup, value, rawIcon })))
),
setCachedApp: async (app, { rawIcon = true, ttlSeconds } = {}) => {
if ( ! app ) return;
const serialized = JSON.stringify(app);
const writes = AppRedisCacheSpace.keysForApp(app, { rawIcon })
.map(key => setKey(key, serialized, { ttlSeconds }));
if ( writes.length ) {
await Promise.all(writes);
}
},
invalidateCachedApp: async (app, {
rawIconVariants = [true, false],
includeStats = false,
} = {}) => {
if ( ! app ) return;
const keys = [];
for ( const rawIcon of rawIconVariants ) {
keys.push(...AppRedisCacheSpace.keysForApp(app, { rawIcon }));
}
if ( includeStats && app.uid ) {
keys.push(...AppRedisCacheSpace.statsKeys(app.uid));
}
if ( keys.length ) {
await redisClient.del(...keys);
}
},
invalidateCachedAppName: async (name, { rawIconVariants = [true, false] } = {}) => {
if ( ! name ) return;
const keys = rawIconVariants.map(rawIcon => appCacheKey({
lookup: 'name',
value: name,
rawIcon,
}));
await redisClient.del(...keys);
},
invalidateAppStats: async (uid) => {
if ( ! uid ) return;
await redisClient.del(...AppRedisCacheSpace.statsKeys(uid));
},
};
@@ -0,0 +1,21 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export const RecommendedAppsRedisCacheSpace = {
key: ({ iconSize } = {}) => `global:recommended-apps${iconSize ? `:icon-size:${iconSize}` : ''}`,
};
@@ -20,6 +20,7 @@
import { redisClient } from '../../clients/redis/redisSingleton.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 = [
@@ -82,23 +83,22 @@ export default class RecommendedAppsService extends BaseService {
if ( ! this.app_names.has(name) ) return;
}
const deletions = [redisClient.del('global:recommended-apps')];
const deletions = [redisClient.del(RecommendedAppsRedisCacheSpace.key())];
for ( const size of sizes ) {
const key = `global:recommended-apps:icon-size:${size}`;
const key = RecommendedAppsRedisCacheSpace.key({ iconSize: size });
deletions.push(redisClient.del(key));
}
await Promise.all(deletions);
});
}
async get_recommended_apps ({ icon_size }) {
const recommended_cache_key = `global:recommended-apps${
icon_size ? `:icon-size:${icon_size}` : ''}`;
async get_recommended_apps ({ icon_size: iconSize }) {
const recommendedCacheKey = RecommendedAppsRedisCacheSpace.key({ iconSize });
const cached_recommended = await redisClient.get(recommended_cache_key);
if ( cached_recommended ) {
const cachedRecommended = await redisClient.get(recommendedCacheKey);
if ( cachedRecommended ) {
try {
return JSON.parse(cached_recommended);
return JSON.parse(cachedRecommended);
} catch (e) {
// no op cache is in an invalid state
}
@@ -121,14 +121,14 @@ export default class RecommendedAppsService extends BaseService {
const svc_appIcon = this.services.get('app-icon');
// Iconify apps
if ( icon_size ) {
if ( iconSize ) {
recommended = await svc_appIcon.iconifyApps({
apps: recommended,
size: icon_size,
size: iconSize,
});
}
await redisClient.set(recommended_cache_key, JSON.stringify(recommended));
await redisClient.set(recommendedCacheKey, JSON.stringify(recommended));
return recommended;
}
+1 -1
View File
@@ -64,7 +64,7 @@ class Core2Module extends AdvancedBase {
const { ProcessEventService } = require('./ProcessEventService.js');
services.registerService('process-event', ProcessEventService);
const { ServerHealthService } = require('./ServerHealthService.js');
const { ServerHealthService } = require('./ServerHealthService/ServerHealthService.js');
services.registerService('server-health', ServerHealthService);
const { ParameterService } = require('./ParameterService.js');
@@ -0,0 +1,21 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export const ServerHealthRedisCacheKeys = {
status: 'server-health:status',
};
@@ -16,8 +16,9 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { redisClient } = require('../../clients/redis/redisSingleton');
const BaseService = require('../../services/BaseService');
const { redisClient } = require('../../../clients/redis/redisSingleton');
const { ServerHealthRedisCacheKeys } = require('./ServerHealthRedisCacheKeys.js');
const BaseService = require('../../../services/BaseService');
const { promise } = require('@heyputer/putility').libs;
const SECOND = 1000;
@@ -221,10 +222,10 @@ class ServerHealthService extends BaseService {
* - `failed` {Array<string>}: An array of names of failed health checks, if any.
*/
async get_status () {
const cache_key = 'server-health:status';
const cacheKey = ServerHealthRedisCacheKeys.status;
// Check cache first
const cached = await redisClient.get(cache_key);
const cached = await redisClient.get(cacheKey);
if ( cached ) {
try {
return JSON.parse(cached);
@@ -241,7 +242,7 @@ class ServerHealthService extends BaseService {
};
// Cache with 5 second TTL
await redisClient.set(cache_key, JSON.stringify(status), 'EX', 5);
await redisClient.set(cacheKey, JSON.stringify(status), 'EX', 5);
return status;
}
@@ -1,5 +1,5 @@
import { createTestKernel } from '../../../tools/test.mjs';
import helpers from '../../helpers.js';
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';
@@ -68,7 +68,7 @@ const testWithEachService = async (fnToRunOnBoth, {
} = {}) => {
return await fixContextInitialization(async () => {
const setupUserAndRunWithContext = async (params, fn) => {
const { kernel, key } = params;
const { kernel } = params;
const db = kernel.services.get('database').get('write', 'test');
const userId = 1;
const username = 'testuser';
@@ -124,7 +124,7 @@ const testWithEachService = async (fnToRunOnBoth, {
'es:app': ES_APP_ARGS,
},
});
await helpers.tmp_provide_services(esAppTestKernel.services);
await tmp_provide_services(esAppTestKernel.services);
const appTestKernel = await createTestKernel({
testCore: true,
@@ -137,11 +137,11 @@ const testWithEachService = async (fnToRunOnBoth, {
'app': AppService,
},
});
await helpers.tmp_provide_services(appTestKernel.services);
await tmp_provide_services(appTestKernel.services);
helpers.tmp_provide_services(appTestKernel.services);
tmp_provide_services(appTestKernel.services);
await setupUserAndRunWithContext({ kernel: appTestKernel, key: 'app' }, fnToRunOnBoth);
helpers.tmp_provide_services(esAppTestKernel.services);
tmp_provide_services(esAppTestKernel.services);
if ( fnToRunOnTheOther ) {
await setupUserAndRunWithContext({ kernel: esAppTestKernel, key: 'es:app' }, fnToRunOnTheOther);
} else {
@@ -1,7 +1,9 @@
import { v4 as uuidv4 } from 'uuid';
import APIError from '../../api/APIError.js';
import { redisClient } from '../../clients/redis/redisSingleton.js';
import config from '../../config.js';
import { AppRedisCacheSpace } from '../apps/AppRedisCacheSpace.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';
@@ -394,11 +396,11 @@ export default class AppService extends BaseService {
// REFINED BY OTHER DATA
// app.icon;
if ( params.icon_size && svc_appIcon ) {
const icon_size = params.icon_size;
const iconSize = params.icon_size;
try {
const iconPath = svc_appIcon.getAppIconPath({
appUid: row.uid,
size: icon_size,
size: iconSize,
});
if ( iconPath ) {
app.icon = iconPath;
@@ -523,12 +525,12 @@ export default class AppService extends BaseService {
}
if ( params.icon_size ) {
const icon_size = params.icon_size;
const iconSize = params.icon_size;
const svc_appIcon = this.context.get('services').get('app-icon');
try {
const iconPath = svc_appIcon.getAppIconPath({
appUid: row.uid,
size: icon_size,
size: iconSize,
});
if ( iconPath ) {
app.icon = iconPath;
@@ -966,15 +968,9 @@ export default class AppService extends BaseService {
await this.#update_filetype_associations(insert_id, object.filetype_associations);
}
// Emit events for icon/name changes
// Emit events for icon/name or app changes
await this.#emit_change_events(object, old_app);
const svc_event = this.services.get('event');
svc_event.emit('app.changed', {
app_uid: old_app.uid,
action: 'updated',
});
// Return the updated app (re-fetch for client-safe output)
// TODO: optimize this
return await this.#read({ uid: old_app.uid });
@@ -1151,25 +1147,52 @@ export default class AppService extends BaseService {
}
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 ( !filetype_associations || !(filetype_associations.length > 0) ) {
if ( ! normalizedNew.length ) {
const affectedExtensions = new Set(normalizedOld);
if ( affectedExtensions.size ) {
await redisClient.del(...Array.from(affectedExtensions)
.map(ext => AppRedisCacheSpace.associationAppsKey(ext)));
}
return;
}
const stmt =
`INSERT INTO app_filetype_association (app_id, type) VALUES ${
filetype_associations.map(() => '(?, ?)').join(', ')}`;
const values = filetype_associations.flatMap(ft => [app_id, ft.toLowerCase()]);
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 redisClient.del(...Array.from(affectedExtensions)
.map(ext => AppRedisCacheSpace.associationAppsKey(ext)));
}
}
async #emit_change_events (object, old_app) {
const svc_event = this.services.get('event');
await svc_event.emit('app.changed', {
app_uid: old_app.uid,
action: 'updated',
});
// Emit icon change event
if ( object.icon !== undefined && object.icon !== old_app.icon ) {
const event = {
@@ -184,7 +184,7 @@ class SizeService extends BaseService {
alarm: true,
});
}
invalidate_cached_user_by_id(user.id);
await invalidate_cached_user_by_id(user.id);
}
}
}
@@ -130,7 +130,7 @@ class DefaultUserService extends BaseService {
await this.#createDefaultUserFiles(actor);
invalidate_cached_user(user);
await invalidate_cached_user(user);
await new Promise(rslv => setTimeout(rslv, 2000));
return user;
}
@@ -251,7 +251,7 @@ class DefaultUserService extends BaseService {
{
id: 'reset-password',
handler: async (args, ctx) => {
const [ username ] = args;
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}`);
@@ -45,7 +45,7 @@ class SelfhostedService extends BaseService {
}
await db.write('UPDATE apps SET godmode = 1 WHERE uid = ?', [app_uid]);
const svc_event = this.services.get('event');
svc_event.emit('app.changed', {
await svc_event.emit('app.changed', {
app_uid,
action: 'updated',
});
@@ -68,7 +68,7 @@ class SelfhostedService extends BaseService {
}
await db.write('UPDATE apps SET godmode = 0 WHERE uid = ?', [app_uid]);
const svc_event = this.services.get('event');
svc_event.emit('app.changed', {
await svc_event.emit('app.changed', {
app_uid,
action: 'updated',
});
+26 -6
View File
@@ -17,6 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const APIError = require('../../api/APIError');
const { AppRedisCacheSpace } = require('../../modules/apps/AppRedisCacheSpace.js');
const { redisClient } = require('../../clients/redis/redisSingleton');
const config = require('../../config');
const { app_name_exists } = require('../../helpers');
const { AppUnderUserActorType } = require('../../services/auth/Actor');
@@ -147,6 +149,13 @@ class AppES extends BaseES {
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 ) {
@@ -156,14 +165,25 @@ class AppES extends BaseES {
// 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 ${
filetype_associations.map(() => '(?, ?)').join(', ')}`;
const rows = filetype_associations.map(a => [insert_id, a.toLowerCase()]);
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 redisClient.del(...Array.from(affectedAssociationExtensions)
.map(ext => AppRedisCacheSpace.associationAppsKey(ext)));
}
const has_new_icon =
( !extra.old_entity ) || (
@@ -207,7 +227,7 @@ class AppES extends BaseES {
}
if ( extra.old_entity ) {
const svc_event = this.context.get('services').get('event');
svc_event.emit('app.changed', {
await svc_event.emit('app.changed', {
app_uid: await full_entity.get('uid'),
action: 'updated',
});
@@ -333,13 +353,13 @@ class AppES extends BaseES {
}
// Replace icon if an icon size is specified
const icon_size = Context.get('es_params')?.icon_size;
if ( icon_size ) {
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: icon_size,
size: iconSize,
});
if ( iconPath ) {
await entity.set('icon', iconPath);
+2 -2
View File
@@ -214,7 +214,7 @@ router.all('*', async function (req, res, next) {
await db.write('UPDATE `user` SET `unsubscribed` = 1 WHERE id = ?',
[user.id]);
invalidate_cached_user(user);
await invalidate_cached_user(user);
// return results
h += '<p style="text-align:center; color:green;">Your have successfully unsubscribed from all emails.</p>';
@@ -283,7 +283,7 @@ router.all('*', async function (req, res, next) {
// update user
await db.write('UPDATE `user` SET `email_confirmed` = 1, `requires_email_confirmation` = 0 WHERE id = ?',
[user.id]);
invalidate_cached_user(user);
await invalidate_cached_user(user);
// send realtime success msg to client
const svc_socketio = req.services.get('socketio');
@@ -18,7 +18,7 @@
*/
const APIError = require('../../api/APIError');
const eggspress = require('../../api/eggspress');
const { get_user } = require('../../helpers');
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');
@@ -27,7 +27,7 @@ module.exports = eggspress('/auth/configure-2fa/:action', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
}, async (req, res) => {
const action = req.params.action;
const x = Context.get();
@@ -75,6 +75,7 @@ module.exports = eggspress('/auth/configure-2fa/:action', {
// update user
await db.write('UPDATE user SET otp_secret = ?, otp_recovery_codes = ? WHERE uuid = ?',
[result.secret, hashed_recovery_codes.join(','), user.uuid]);
await invalidate_cached_user_by_id(req.user.id);
req.user.otp_secret = result.secret;
req.user.otp_recovery_codes = hashed_recovery_codes.join(',');
user.otp_secret = result.secret;
@@ -120,6 +121,7 @@ module.exports = eggspress('/auth/configure-2fa/:action', {
await db.write('UPDATE user SET otp_enabled = 1 WHERE uuid = ?',
[user.uuid]);
await invalidate_cached_user_by_id(req.user.id);
// update cached user
req.user.otp_enabled = 1;
@@ -25,7 +25,7 @@ module.exports = eggspress('/delete-own-user', {
subdomain: 'api',
auth: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
}, async (req, res) => {
const bcrypt = require('bcrypt');
const validate_request = async () => {
@@ -55,7 +55,7 @@ module.exports = eggspress('/delete-own-user', {
res.clearCookie(config.cookie_name);
await deleteUser(req.user.id);
invalidate_cached_user(req.user);
await invalidate_cached_user(req.user);
return res.send({ success: true });
});
+2 -2
View File
@@ -28,7 +28,7 @@ const { invalidate_cached_user_by_id } = require('../helpers.js');
const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', {
allowedMethods: ['GET'],
}, async (req, res, next) => {
}, async (req, res ) => {
const jwt_token = req.query.token;
if ( ! jwt_token ) {
@@ -74,7 +74,7 @@ const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', {
new_email,
});
invalidate_cached_user_by_id(user_id);
await invalidate_cached_user_by_id(user_id);
const svc_socketio = req.services.get('socketio');
svc_socketio.send({ room: user_id }, 'user.email_changed', {});
@@ -0,0 +1,23 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const ConfirmEmailRedisCacheSpace = {
key: ({ ipAddress, emailOrUsername }) => `confirm-email|${ipAddress}|${emailOrUsername}`,
};
export { ConfirmEmailRedisCacheSpace };
@@ -19,17 +19,18 @@
'use strict';
const express = require('express');
const router = new express.Router();
const auth = require('../middleware/auth.js');
const { DB_WRITE } = require('../services/database/consts');
const APIError = require('../api/APIError.js');
const { redisClient } = require('../clients/redis/redisSingleton.js');
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');
// -----------------------------------------------------------------------//
// POST /confirm-email
// -----------------------------------------------------------------------//
router.post('/confirm-email', auth, express.json(), async (req, res, next) => {
// Either api. subdomain or no subdomain
if ( require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '' )
if ( require('../../helpers.js').subdomain(req) !== 'api' && require('../../helpers.js').subdomain(req) !== '' )
{
next();
}
@@ -48,12 +49,16 @@ router.post('/confirm-email', auth, express.json(), async (req, res, next) => {
const db = req.services.get('database').get(DB_WRITE, 'auth');
// Increment & check rate limit
if ( await redisClient.incr(`confirm-email|${req.ip}|${req.user.email ?? req.user.username}`) > 10 )
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(`confirm-email|${req.ip}|${req.user.email ?? req.user.username}`, 60 * 10, 'NX');
redisClient.expire(rateLimitKey, 60 * 10, 'NX');
if ( req.body.code !== req.user.email_confirm_code ) {
res.send({ email_confirmed: false });
+2 -2
View File
@@ -44,7 +44,7 @@ router.get('/get-dev-profile', auth, express.json(), async (req, response, next)
// 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);
await require('../helpers').invalidate_cached_user(req.user);
const { get_user } = require('../helpers');
let dev = await get_user(req.user);
@@ -64,4 +64,4 @@ router.get('/get-dev-profile', auth, express.json(), async (req, response, next)
response.status(400).send();
}
});
module.exports = router;
module.exports = router;
+9 -7
View File
@@ -19,6 +19,7 @@
'use strict';
import { redisClient } from '../clients/redis/redisSingleton.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 }) => {
@@ -31,12 +32,13 @@ const iconify_apps = async (context, { apps, size }) => {
// -----------------------------------------------------------------------//
export default async (req, res) => {
let result = {};
const iconSize = req.query.icon_size;
// Verify query params
if ( req.query.icon_size ) {
if ( iconSize ) {
const ALLOWED_SIZES = ['16', '32', '64', '128', '256', '512'];
if ( ! ALLOWED_SIZES.includes(req.query.icon_size) ) {
if ( ! ALLOWED_SIZES.includes(iconSize) ) {
res.status(400).send({ error: 'Invalid icon_size' });
}
}
@@ -46,7 +48,7 @@ export default async (req, res) => {
// -----------------------------------------------------------------------//
const svc_recommendedApps = req.services.get('recommended-apps');
result.recommended = await svc_recommendedApps.get_recommended_apps({
icon_size: req.query.icon_size,
icon_size: iconSize,
});
// -----------------------------------------------------------------------//
@@ -57,7 +59,7 @@ export default async (req, res) => {
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(`app_opens:user:${ req.user.id}`);
const cached_apps = await redisClient.get(RecentAppOpensRedisCacheSpace.key(req.user.id));
if ( cached_apps ) {
try {
apps = JSON.parse(cached_apps);
@@ -72,7 +74,7 @@ export default async (req, res) => {
[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 redisClient.set(`app_opens:user:${ req.user.id}`, JSON.stringify(apps));
await redisClient.set(RecentAppOpensRedisCacheSpace.key(req.user.id), JSON.stringify(apps));
}
}
@@ -94,10 +96,10 @@ export default async (req, res) => {
}).filter(Boolean);
// Iconify apps
if ( req.query.icon_size ) {
if ( iconSize ) {
result.recent = await iconify_apps({ services: req.services }, {
apps: result.recent,
size: req.query.icon_size,
size: iconSize,
});
}
+1 -1
View File
@@ -311,7 +311,7 @@ router.post('/login/recovery-code', express.json(), body_parser_error_handler, r
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);
await invalidate_cached_user(user);
return await complete_({ req, res, user });
});
+2 -2
View File
@@ -77,7 +77,7 @@ router.post('/passwd', auth, express.json(), async (req, res, next) => {
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);
await invalidate_cached_user(req.user);
const svc_email = req.services.get('email');
svc_email.send_email({ email: user.email }, 'password_change_notification');
@@ -89,4 +89,4 @@ router.post('/passwd', auth, express.json(), async (req, res, next) => {
}
});
module.exports = router;
module.exports = router;
@@ -0,0 +1,23 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const RecentAppOpensRedisCacheSpace = {
key: userId => `app_opens:user:${userId}`,
};
export { RecentAppOpensRedisCacheSpace };
@@ -21,13 +21,14 @@
'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 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 { RecentAppOpensRedisCacheSpace } = require('./RecentAppOpensRedisCacheSpace.js');
// -----------------------------------------------------------------------//
// POST /rao
@@ -35,7 +36,7 @@ const { redisClient } = require('../clients/redis/redisSingleton');
router.post('/rao', configurable_auth(), express.json(), async (req, res, next) => {
const { actor } = req;
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
if ( require('../../helpers').subdomain(req) !== 'api' )
{
next();
}
@@ -90,7 +91,7 @@ router.post('/rao', configurable_auth(), express.json(), async (req, res, next)
// -----------------------------------------------------------------------//
// First try the cache to see if we have recent apps
let recent_apps;
const recent_apps_raw = await redisClient.get(`app_opens:user:${ req.user.id}`);
const recent_apps_raw = await redisClient.get(RecentAppOpensRedisCacheSpace.key(req.user.id));
if ( recent_apps_raw ) {
try {
recent_apps = JSON.parse(recent_apps_raw);
@@ -111,17 +112,17 @@ router.post('/rao', configurable_auth(), express.json(), async (req, res, next)
recent_apps = recent_apps.slice(0, 10);
// update cache
await redisClient.set(`app_opens:user:${ req.user.id}`, JSON.stringify(recent_apps));
await redisClient.set(RecentAppOpensRedisCacheSpace.key(req.user.id), JSON.stringify(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( ([apps]) => {
// Update cache with the results from the db (if any results were returned)
if ( apps && Array.isArray(apps) && apps.length > 0 ) {
redisClient.set(`app_opens:user:${ req.user.id}`, JSON.stringify(apps));
}
});
// Update cache with the results from the db (if any results were returned)
if ( apps && Array.isArray(apps) && apps.length > 0 ) {
redisClient.set(RecentAppOpensRedisCacheSpace.key(req.user.id), JSON.stringify(apps));
}
});
}
// Update clients
+1 -2
View File
@@ -47,7 +47,6 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => {
const db = req.services.get('database').get(DB_WRITE, 'auth');
const validator = require('validator');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
// validation
@@ -184,7 +183,7 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => {
// id
req.user.id,
]);
invalidate_cached_user(req.user);
await 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',
@@ -53,7 +53,7 @@ router.post('/send-confirm-email', auth, express.json(), async (req, res, next)
// id
req.user.id,
]);
invalidate_cached_user(req.user);
await invalidate_cached_user(req.user);
// send email verification
send_email_verification_code(email_confirm_code, req.user.email);
@@ -61,4 +61,4 @@ router.post('/send-confirm-email', auth, express.json(), async (req, res, next)
res.send();
});
module.exports = router;
module.exports = router;
@@ -108,11 +108,10 @@ router.post('/send-pass-recovery-email', express.json(), body_parser_error_handl
// set pass_recovery_token
const { v4: uuidv4 } = require('uuid');
const nodemailer = require('nodemailer');
const token = uuidv4();
await db.write('UPDATE user SET pass_recovery_token=? WHERE `id` = ?',
[token, user.id]);
invalidate_cached_user(user);
await invalidate_cached_user(user);
// create jwt
const jwt_token = jwt.sign({
@@ -147,4 +146,4 @@ router.post('/send-pass-recovery-email', express.json(), body_parser_error_handl
});
module.exports = router;
module.exports = router;
+2 -2
View File
@@ -51,9 +51,9 @@ router.post('/set-desktop-bg', auth, express.json(), async (req, res, next) => {
req.body.fit ?? null,
req.user.id,
]);
invalidate_cached_user(req.user);
await invalidate_cached_user(req.user);
// send results to client
return res.send({});
});
module.exports = router;
module.exports = router;
@@ -84,7 +84,7 @@ router.post('/set-pass-using-token', express.json(), async (req, res, next) => {
return res.status(400).send(SAFE_NEGATIVE_RESPONSE);
}
invalidate_cached_user_by_id(req.body.user_id);
await invalidate_cached_user_by_id(user.id);
return res.send('Password successfully updated.');
} catch (e) {
@@ -92,4 +92,4 @@ router.post('/set-pass-using-token', express.json(), async (req, res, next) => {
}
});
module.exports = router;
module.exports = router;
+1 -2
View File
@@ -66,7 +66,6 @@ module.exports = eggspress(['/signup'], {
const db = req.services.get('database').get(DB_WRITE, 'auth');
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
const jwt = require('jsonwebtoken');
const validator = require('validator');
let uuid_user;
@@ -411,7 +410,7 @@ module.exports = eggspress(['/signup'], {
// 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);
await invalidate_cached_user_by_id(pseudo_user.id);
}
// user id
@@ -61,9 +61,9 @@ router.post('/update-taskbar-items', auth, express.json(), async (req, res, next
req.user.id,
]);
invalidate_cached_user(req.user);
await invalidate_cached_user(req.user);
// send results to client
return res.send({});
});
module.exports = router;
module.exports = router;
@@ -24,11 +24,12 @@ 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, next) => {
handler: async (req, res) => {
const user = req.user;
const new_email = req.body.new_email;
@@ -71,6 +72,7 @@ module.exports = {
const email_confirm_token = uuidv4();
await db.write('UPDATE `user` SET `email` = ?, `email_confirm_token` = ? WHERE `id` = ?',
[new_email, email_confirm_token, user.id]);
await 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}`;
@@ -102,6 +104,7 @@ module.exports = {
// update user
await db.write('UPDATE `user` SET `unconfirmed_change_email` = ?, `change_email_confirm_token` = ? WHERE `id` = ?',
[new_email, token, user.id]);
await invalidate_cached_user_by_id(user.id);
// Update email change audit table
await db.write('INSERT INTO `user_update_audit` ' +
@@ -75,7 +75,7 @@ const check_password_strength = (password) => {
module.exports = {
route: '/change-password',
methods: ['POST'],
handler: async (req, res, next) => {
handler: async (req, res) => {
// Validate new password
const { new_pass } = req.body;
const { overallPass: strong } = check_password_strength(new_pass);
@@ -89,7 +89,7 @@ module.exports = {
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);
await invalidate_cached_user(req.user);
// Notify user about password change
// TODO: audit log for user in security tab
@@ -17,14 +17,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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, next) => {
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]);
await invalidate_cached_user_by_id(req.user.id);
// update cached user
req.user.otp_enabled = 0;
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ServerHealthService } from '../modules/core/ServerHealthService';
import type { ServerHealthService } from '../modules/core/ServerHealthService/ServerHealthService';
import { SqliteDatabaseAccessService } from './database/SqliteDatabaseAccessService';
import { MeteringServiceWrapper } from './MeteringService/MeteringServiceWrapper.mjs';
import { DDBClient } from '../clients/dynamodb/DDBClient';
+7 -19
View File
@@ -16,11 +16,11 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { redisClient } = require('../clients/redis/redisSingleton');
const { UserActorType } = require('./auth/Actor');
const { PermissionImplicator } = require('./auth/permissionUtils.mjs');
const BaseService = require('./BaseService');
const { DB_READ } = require('./database/consts');
const { UserRedisCacheSpace } = require('./UserRedisCacheSpace.js');
/**
* Get user by one of a variety of identifying properties.
@@ -96,14 +96,9 @@ class GetUserService extends BaseService {
if ( cached && !options.force ) {
for ( const prop of this.id_properties ) {
if ( Object.prototype.hasOwnProperty.call(options, prop) ) {
const cached_user = await redisClient.get(`users:${prop}:${options[prop]}`);
if ( cached_user ) {
try {
user = JSON.parse(cached_user);
} catch (e) {
console.warn(e);
// no-op cache in invalid state
}
const cachedUser = await UserRedisCacheSpace.getByProperty(prop, options[prop]);
if ( cachedUser ) {
user = cachedUser;
}
}
}
@@ -117,16 +112,9 @@ class GetUserService extends BaseService {
await svc_whoami.get_details({ user }, user);
try {
const cached_user = JSON.stringify(user);
const cache_sets = [];
for ( const prop of this.id_properties ) {
if ( user[prop] ) {
cache_sets.push(redisClient.set(`users:${prop}:${user[prop]}`, cached_user));
}
}
if ( cache_sets.length ) {
await Promise.all(cache_sets);
}
await UserRedisCacheSpace.setUser(user, {
props: Array.from(this.id_properties),
});
} catch ( e ) {
console.error(e);
}
+2 -2
View File
@@ -58,7 +58,7 @@ class PuterAPIService extends BaseService {
app.use(require('../routers/drivers/call'));
app.use(require('../routers/drivers/list-interfaces'));
app.use(require('../routers/drivers/usage'));
app.use(require('../routers/confirm-email'));
app.use(require('../routers/confirmEmail/confirm-email'));
app.use(require('../routers/down'));
app.use(require('../routers/contactUs'));
app.use(require('../routers/delete-site'));
@@ -73,7 +73,7 @@ class PuterAPIService extends BaseService {
app.use(require('../routers/logout'));
app.use(require('../routers/open_item'));
app.use(require('../routers/passwd'));
app.use(require('../routers/rao'));
app.use(require('../routers/recentAppOpens/rao'));
app.use(require('../routers/remove-site-dir'));
app.use(require('../routers/removeItem'));
app.use(require('../routers/save_account'));
@@ -19,7 +19,7 @@
const seedrandom = require('seedrandom');
const { generate_random_code } = require('../util/identifier');
const { Context } = require('../util/context');
const { get_user } = require('../helpers');
const { get_user, invalidate_cached_user_by_id } = require('../helpers');
const { DB_WRITE } = require('./database/consts');
const BaseService = require('./BaseService');
const { UserIDNotifSelector } = require('./NotificationService');
@@ -95,9 +95,10 @@ class ReferralCodeService extends BaseService {
referral_code = generate_random_code(8, { rng });
}
try {
db.write(`
await db.write(`
UPDATE user SET referral_code=? WHERE id=?
`, [referral_code, user.id]);
await invalidate_cached_user_by_id(user.id);
return referral_code;
} catch (e) {
last_error = e;
+5 -4
View File
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { redisClient } = require('../clients/redis/redisSingleton');
const { UserRedisCacheSpace } = require('./UserRedisCacheSpace.js');
const { get_user } = require('../helpers');
const { asyncSafeSetInterval } = require('@heyputer/putility').libs.promise;
const SECOND = 1000;
@@ -234,12 +235,12 @@ class SessionService extends BaseService {
'SET `last_activity_ts` = ? ' +
'WHERE `id` = ? LIMIT 1',
[sql_ts, user_id]);
const cached_user = await redisClient.get(`users:id:${ user_id}`);
if ( cached_user ) {
const cachedUser = await redisClient.get(UserRedisCacheSpace.key('id', user_id));
if ( cachedUser ) {
try {
const user = JSON.parse(cached_user);
const user = JSON.parse(cachedUser);
user.last_activity_ts = sql_ts;
await redisClient.set(`users:id:${user_id}`, JSON.stringify(user));
await UserRedisCacheSpace.setUser(user);
} catch ( e ) {
console.warn(e);
// ignore malformed cache entries
@@ -0,0 +1,78 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { redisClient } from '../clients/redis/redisSingleton.js';
const userKeyPrefix = 'users';
const defaultUserIdProperties = ['username', 'uuid', 'email', 'id', 'referral_code'];
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 userCacheKey = (prop, value) => `${userKeyPrefix}:${prop}:${value}`;
const UserRedisCacheSpace = {
key: userCacheKey,
keysForUser: (user, props = defaultUserIdProperties) => {
if ( ! user ) return [];
return props
.filter(prop => user[prop] !== undefined && user[prop] !== null && user[prop] !== '')
.map(prop => userCacheKey(prop, user[prop]));
},
getByProperty: async (prop, value) => safeParseJson(await redisClient.get(userCacheKey(prop, value))),
getById: async (id) => UserRedisCacheSpace.getByProperty('id', id),
setUser: async (user, { props = defaultUserIdProperties, ttlSeconds } = {}) => {
if ( ! user ) return;
const serialized = JSON.stringify(user);
const writes = [];
for ( const prop of props ) {
if ( user[prop] === undefined || user[prop] === null || user[prop] === '' ) continue;
writes.push(setKey(userCacheKey(prop, user[prop]), serialized, { ttlSeconds }));
}
if ( writes.length ) {
await Promise.all(writes);
}
},
invalidateUser: async (user, props = defaultUserIdProperties) => {
const keys = UserRedisCacheSpace.keysForUser(user, props);
if ( keys.length ) {
await redisClient.del(...keys);
}
},
invalidateById: async (id, props = defaultUserIdProperties) => {
const user = await UserRedisCacheSpace.getById(id);
if ( ! user ) return;
await UserRedisCacheSpace.invalidateUser(user, props);
},
};
export { UserRedisCacheSpace };
+9 -2
View File
@@ -18,7 +18,7 @@
*/
const { RootNodeSelector, NodeChildSelector } = require('../filesystem/node/selectors');
const { invalidate_cached_user } = require('../helpers');
const { invalidate_cached_user, invalidate_cached_user_by_id } = require('../helpers');
const BaseService = require('./BaseService');
const { DB_WRITE } = require('./database/consts');
@@ -136,7 +136,7 @@ class UserService extends BaseService {
trash_id, appdata_id, desktop_id, documents_id, pictures_id, videos_id, public_id,
user.id,
]);
invalidate_cached_user(user);
await invalidate_cached_user(user);
}
async updateUserMetadata (userId, updatedMetadata) {
@@ -164,6 +164,13 @@ class UserService extends BaseService {
// Save back to DB - always stringify for compatibility with both databases
await this.db.write('UPDATE `user` SET metadata=? WHERE uuid=?', [JSON.stringify(metadata), userId]);
const refreshed_user = await this.services.get('get-user').get_user({
uuid: userId,
force: true,
});
if ( refreshed_user?.id ) {
await invalidate_cached_user_by_id(refreshed_user.id);
}
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export const fallbackModelsKey = (modelId: string) => `aichat:fallbacks:${modelId}`;
@@ -46,6 +46,7 @@ import { OpenRouterProvider } from './providers/OpenRouterProvider/OpenRouterPro
import { TogetherAIProvider } from './providers/TogetherAiProvider/TogetherAIProvider.js';
import { IChatModel, IChatProvider, ICompleteArguments } from './providers/types.js';
import { XAIProvider } from './providers/XAIProvider/XAIProvider.js';
import { fallbackModelsKey } from './AIChatRedisCacheSpace.js';
// Maximum number of fallback attempts when a model fails, including the first attempt
const MAX_FALLBACKS = 3 + 1; // includes first attempt
@@ -686,7 +687,7 @@ export class AIChatService extends BaseService {
// First check KV for the sorted list
let potentialFallbacks;
const cached_fallbacks = await redisClient.get(`aichat:fallbacks:${targetModel.id}`);
const cached_fallbacks = await redisClient.get(fallbackModelsKey(targetModel.id));
if ( cached_fallbacks ) {
try {
potentialFallbacks = JSON.parse(cached_fallbacks);
@@ -714,7 +715,7 @@ export class AIChatService extends BaseService {
return !!possibleModelNames.find(possibleName => model.id.toLowerCase() === possibleName);
}).slice(0, MAX_FALLBACKS);
await redisClient.set(`aichat:fallbacks:${modelId}`, JSON.stringify(potentialMatches));
await redisClient.set(fallbackModelsKey(modelId), JSON.stringify(potentialMatches));
potentialFallbacks = potentialMatches;
}
@@ -23,6 +23,7 @@ const { TypedValue } = require('../../drivers/meta/Runtime');
const APIError = require('../../../api/APIError');
const { Context } = require('../../../util/context');
const { redisClient } = require('../../../clients/redis/redisSingleton');
const { PollyRedisCacheKeys } = require('./PollyRedisCacheKeys.js');
// Polly price calculation per engine
const ENGINE_PRICING = {
@@ -190,7 +191,7 @@ class AWSPollyService extends BaseService {
* Uses KV store for caching to avoid repeated API calls
*/
async describe_voices () {
const cached_voices = await redisClient.get('svc:polly:voices');
const cached_voices = await redisClient.get(PollyRedisCacheKeys.voices);
if ( cached_voices ) {
try {
const voices = JSON.parse(cached_voices);
@@ -211,7 +212,7 @@ class AWSPollyService extends BaseService {
const response = await client.send(command);
await redisClient.set('svc:polly:voices', JSON.stringify(response), 'EX', 60 * 10); // 10 minutes
await redisClient.set(PollyRedisCacheKeys.voices, JSON.stringify(response), 'EX', 60 * 10); // 10 minutes
return response;
}
@@ -0,0 +1,23 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const PollyRedisCacheKeys = {
voices: 'svc:polly:voices',
};
export { PollyRedisCacheKeys };
@@ -0,0 +1,23 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const GroupRedisCacheSpace = {
publicGroupsKey: kvKey => `${kvKey}:public-groups`,
};
export { GroupRedisCacheSpace };
@@ -18,6 +18,7 @@
*/
const APIError = require('../../api/APIError');
const { redisClient } = require('../../clients/redis/redisSingleton');
const { GroupRedisCacheSpace } = require('./GroupRedisCacheSpace.js');
const Group = require('../../entities/Group');
const { DENY_SERVICE_INSTRUCTION } = require('../AnomalyService');
const BaseService = require('../BaseService');
@@ -184,8 +185,8 @@ class GroupService extends BaseService {
this.global_config.default_temp_group,
];
const cache_key = `${this.kvkey}:public-groups`;
const cached_groups = await redisClient.get(cache_key);
const cacheKey = GroupRedisCacheSpace.publicGroupsKey(this.kvkey);
const cached_groups = await redisClient.get(cacheKey);
if ( cached_groups ) {
try {
return JSON.parse(cached_groups).map(g => Group(g));
@@ -209,7 +210,7 @@ class GroupService extends BaseService {
})();
}
const group_entities = groups.map(g => Group(g));
await redisClient.set(cache_key, JSON.stringify(groups), 'EX', 60);
await redisClient.set(cacheKey, JSON.stringify(groups), 'EX', 60);
return group_entities;
}
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const PermissionScanRedisCacheSpace = {
key: ({ actorUid, permissionOptions, joinPermissionParts }) => (
joinPermissionParts('permission-scan', actorUid, 'options-list', ...permissionOptions)
),
};
export { PermissionScanRedisCacheSpace };
@@ -29,6 +29,7 @@ const { PERM_KEY_PREFIX, MANAGE_PERM_PREFIX } = require('./permissionConts.mjs')
const { PermissionUtil, PermissionExploder, PermissionImplicator, PermissionRewriter } = require('./permissionUtils.mjs');
const { spanify } = require('../../util/otelutil');
const { redisClient } = require('../../clients/redis/redisSingleton');
const { PermissionScanRedisCacheSpace } = require('./PermissionScanRedisCacheSpace.js');
const { Context } = require('../../util/context');
/**
@@ -197,12 +198,13 @@ class PermissionService extends BaseService {
permission_options = [permission_options];
}
const cache_str = PermissionUtil.join('permission-scan',
actor.uid,
'options-list',
...permission_options);
const cacheKey = PermissionScanRedisCacheSpace.key({
actorUid: actor.uid,
permissionOptions: permission_options,
joinPermissionParts: PermissionUtil.join,
});
const cached = await redisClient.get(cache_str);
const cached = await redisClient.get(cacheKey);
if ( cached && !scan_options.no_cache ) {
try {
return JSON.parse(cached);
@@ -233,7 +235,7 @@ class PermissionService extends BaseService {
value: end_ts - start_ts,
});
await redisClient.set(cache_str, JSON.stringify(reading), 'EX', 20);
await redisClient.set(cacheKey, JSON.stringify(reading), 'EX', 20);
return reading;
}
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const RateLimitRedisCacheSpace = {
keyPrefix: consumerScopedKey => `rate-limit:${consumerScopedKey}`,
windowStartKey: consumerScopedKey => `${RateLimitRedisCacheSpace.keyPrefix(consumerScopedKey)}:window_start`,
countKey: consumerScopedKey => `${RateLimitRedisCacheSpace.keyPrefix(consumerScopedKey)}:count`,
};
export { RateLimitRedisCacheSpace };
@@ -22,6 +22,7 @@ const BaseService = require('../BaseService');
const { SyncFeature } = require('../../traits/SyncFeature');
const { DB_WRITE } = require('../database/consts');
const { redisClient } = require('../../clients/redis/redisSingleton');
const { RateLimitRedisCacheSpace } = require('./RateLimitRedisCacheSpace.js');
const ts_to_sql = (ts) => Math.floor(ts / 1000);
const ts_fr_sql = (ts) => ts * 1000;
@@ -64,11 +65,12 @@ class RateLimitService extends BaseService {
const consumer_id = this._get_consumer_id();
const method_name = key;
key = `${consumer_id}:${key}`;
const kvkey = `rate-limit:${key}`;
const windowStartKey = RateLimitRedisCacheSpace.windowStartKey(key);
const countKey = RateLimitRedisCacheSpace.countKey(key);
const dbkey = options.global ? key : `${this.global_config.server_id}:${key}`;
// Fixed window counter strategy (see devlog 2023-11-21)
const window_start_raw = await redisClient.get(`${kvkey}:window_start`);
const window_start_raw = await redisClient.get(windowStartKey);
let window_start = Number.isFinite(Number(window_start_raw)) ? Number(window_start_raw) : 0;
if ( window_start === 0 ) {
// Try database
@@ -81,8 +83,8 @@ class RateLimitService extends BaseService {
const count = row.count;
await Promise.all([
redisClient.set(`${kvkey}:window_start`, window_start),
redisClient.set(`${kvkey}:count`, count),
redisClient.set(windowStartKey, window_start),
redisClient.set(countKey, count),
]);
}
}
@@ -90,8 +92,8 @@ class RateLimitService extends BaseService {
if ( window_start === 0 ) {
window_start = Date.now();
await Promise.all([
redisClient.set(`${kvkey}:window_start`, window_start),
redisClient.set(`${kvkey}:count`, 0),
redisClient.set(windowStartKey, window_start),
redisClient.set(countKey, 0),
]);
this.db.write('INSERT INTO `rl_usage_fixed_window` (`key`, `window_start`, `count`) VALUES (?, ?, ?)',
@@ -104,15 +106,15 @@ class RateLimitService extends BaseService {
if ( window_start + period < Date.now() ) {
window_start = Date.now();
await Promise.all([
redisClient.set(`${kvkey}:window_start`, window_start),
redisClient.set(`${kvkey}:count`, 0),
redisClient.set(windowStartKey, window_start),
redisClient.set(countKey, 0),
]);
this.db.write('UPDATE `rl_usage_fixed_window` SET `window_start` = ?, `count` = ? WHERE `key` = ?',
[ts_to_sql(window_start), 0, dbkey]);
}
const current_raw = await redisClient.get(`${kvkey}:count`);
const current_raw = await redisClient.get(countKey);
const current = Number.isFinite(Number(current_raw)) ? Number(current_raw) : 0;
if ( current >= max ) {
throw APIError.create('rate_limit_exceeded', null, {
@@ -121,7 +123,7 @@ class RateLimitService extends BaseService {
});
}
await redisClient.incr(`${kvkey}:count`);
await redisClient.incr(countKey);
this.db.write('UPDATE `rl_usage_fixed_window` SET `count` = `count` + 1 WHERE `key` = ?',
[dbkey]);
}
+6 -14
View File
@@ -18,19 +18,19 @@
*/
// Shared validation helpers formerly provided by backend-core-0.
const { is_valid_path } = require('./filesystem/validation');
export { is_valid_path } from './filesystem/validation.js';
const is_valid_uuid = (uuid) => {
export const is_valid_uuid = (uuid) => {
let s = `${ uuid }`;
s = s.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
return !!s;
};
const is_valid_uuid4 = (uuid) => {
export const is_valid_uuid4 = (uuid) => {
return is_valid_uuid(uuid);
};
const is_specifically_uuidv4 = (uuid) => {
export const is_specifically_uuidv4 = (uuid) => {
let s = `${ uuid }`;
s = s.match(/^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i);
@@ -40,7 +40,7 @@ const is_specifically_uuidv4 = (uuid) => {
return true;
};
const is_valid_url = (url) => {
export const is_valid_url = (url) => {
let s = `${ url }`;
try {
@@ -49,12 +49,4 @@ const is_valid_url = (url) => {
} catch (e) {
return false;
}
};
module.exports = {
is_valid_uuid,
is_valid_uuid4,
is_specifically_uuidv4,
is_valid_url,
is_valid_path,
};
};