mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-06 01:20:41 +00:00
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:
@@ -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)
|
||||
# ======================================================================
|
||||
|
||||
Generated
+6266
-3416
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -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"
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
+6
-5
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
+12
-7
@@ -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 });
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user