}: 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;
}
diff --git a/src/backend/src/modules/data-access/AppService.comp.test.js b/src/backend/src/modules/data-access/AppService.comp.test.js
index 7be4e8333..d4a78eab1 100644
--- a/src/backend/src/modules/data-access/AppService.comp.test.js
+++ b/src/backend/src/modules/data-access/AppService.comp.test.js
@@ -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 {
diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js
index 9a5745278..db4f1a87e 100644
--- a/src/backend/src/modules/data-access/AppService.js
+++ b/src/backend/src/modules/data-access/AppService.js
@@ -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 = {
diff --git a/src/backend/src/modules/puterfs/SizeService.js b/src/backend/src/modules/puterfs/SizeService.js
index f3fbc0725..544d92c06 100644
--- a/src/backend/src/modules/puterfs/SizeService.js
+++ b/src/backend/src/modules/puterfs/SizeService.js
@@ -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);
}
}
}
diff --git a/src/backend/src/modules/selfhosted/DefaultUserService.js b/src/backend/src/modules/selfhosted/DefaultUserService.js
index 190c6fcf6..ed7f9c1a8 100644
--- a/src/backend/src/modules/selfhosted/DefaultUserService.js
+++ b/src/backend/src/modules/selfhosted/DefaultUserService.js
@@ -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}`);
diff --git a/src/backend/src/modules/selfhosted/SelfhostedService.js b/src/backend/src/modules/selfhosted/SelfhostedService.js
index 1d71db8e6..d0cf95d8f 100644
--- a/src/backend/src/modules/selfhosted/SelfhostedService.js
+++ b/src/backend/src/modules/selfhosted/SelfhostedService.js
@@ -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',
});
diff --git a/src/backend/src/om/entitystorage/AppES.js b/src/backend/src/om/entitystorage/AppES.js
index f5676ee1a..9134574a6 100644
--- a/src/backend/src/om/entitystorage/AppES.js
+++ b/src/backend/src/om/entitystorage/AppES.js
@@ -17,6 +17,8 @@
* along with this program. If not, see .
*/
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);
diff --git a/src/backend/src/routers/_default.js b/src/backend/src/routers/_default.js
index 5fcad600b..ff1f91491 100644
--- a/src/backend/src/routers/_default.js
+++ b/src/backend/src/routers/_default.js
@@ -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 += 'Your have successfully unsubscribed from all emails.
';
@@ -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');
diff --git a/src/backend/src/routers/auth/configure-2fa.js b/src/backend/src/routers/auth/configure-2fa.js
index 220a02a96..afc961ebd 100644
--- a/src/backend/src/routers/auth/configure-2fa.js
+++ b/src/backend/src/routers/auth/configure-2fa.js
@@ -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;
diff --git a/src/backend/src/routers/auth/delete-own-user.js b/src/backend/src/routers/auth/delete-own-user.js
index 83120f4e8..14746d2f0 100644
--- a/src/backend/src/routers/auth/delete-own-user.js
+++ b/src/backend/src/routers/auth/delete-own-user.js
@@ -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 });
});
diff --git a/src/backend/src/routers/change_email.js b/src/backend/src/routers/change_email.js
index b93c006df..de4857c49 100644
--- a/src/backend/src/routers/change_email.js
+++ b/src/backend/src/routers/change_email.js
@@ -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', {});
diff --git a/src/backend/src/routers/confirmEmail/ConfirmEmailRedisCacheSpace.js b/src/backend/src/routers/confirmEmail/ConfirmEmailRedisCacheSpace.js
new file mode 100644
index 000000000..c1d20a9e5
--- /dev/null
+++ b/src/backend/src/routers/confirmEmail/ConfirmEmailRedisCacheSpace.js
@@ -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 .
+ */
+const ConfirmEmailRedisCacheSpace = {
+ key: ({ ipAddress, emailOrUsername }) => `confirm-email|${ipAddress}|${emailOrUsername}`,
+};
+
+export { ConfirmEmailRedisCacheSpace };
diff --git a/src/backend/src/routers/confirm-email.js b/src/backend/src/routers/confirmEmail/confirm-email.js
similarity index 84%
rename from src/backend/src/routers/confirm-email.js
rename to src/backend/src/routers/confirmEmail/confirm-email.js
index cab4e1637..0870dcf1e 100644
--- a/src/backend/src/routers/confirm-email.js
+++ b/src/backend/src/routers/confirmEmail/confirm-email.js
@@ -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 });
diff --git a/src/backend/src/routers/get-dev-profile.js b/src/backend/src/routers/get-dev-profile.js
index 74aa834e5..5cca0e162 100644
--- a/src/backend/src/routers/get-dev-profile.js
+++ b/src/backend/src/routers/get-dev-profile.js
@@ -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;
\ No newline at end of file
+module.exports = router;
diff --git a/src/backend/src/routers/get-launch-apps.js b/src/backend/src/routers/get-launch-apps.js
index 917833cbe..c504c7e69 100644
--- a/src/backend/src/routers/get-launch-apps.js
+++ b/src/backend/src/routers/get-launch-apps.js
@@ -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,
});
}
diff --git a/src/backend/src/routers/login.js b/src/backend/src/routers/login.js
index 6aac43f51..1e9c4aa32 100644
--- a/src/backend/src/routers/login.js
+++ b/src/backend/src/routers/login.js
@@ -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 });
});
diff --git a/src/backend/src/routers/passwd.js b/src/backend/src/routers/passwd.js
index 1cb4a4c1c..fa378bed7 100644
--- a/src/backend/src/routers/passwd.js
+++ b/src/backend/src/routers/passwd.js
@@ -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;
\ No newline at end of file
+module.exports = router;
diff --git a/src/backend/src/routers/recentAppOpens/RecentAppOpensRedisCacheSpace.js b/src/backend/src/routers/recentAppOpens/RecentAppOpensRedisCacheSpace.js
new file mode 100644
index 000000000..9415ca955
--- /dev/null
+++ b/src/backend/src/routers/recentAppOpens/RecentAppOpensRedisCacheSpace.js
@@ -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 .
+ */
+const RecentAppOpensRedisCacheSpace = {
+ key: userId => `app_opens:user:${userId}`,
+};
+
+export { RecentAppOpensRedisCacheSpace };
diff --git a/src/backend/src/routers/rao.js b/src/backend/src/routers/recentAppOpens/rao.js
similarity index 80%
rename from src/backend/src/routers/rao.js
rename to src/backend/src/routers/recentAppOpens/rao.js
index 0b8bd4707..e9b166990 100644
--- a/src/backend/src/routers/rao.js
+++ b/src/backend/src/routers/recentAppOpens/rao.js
@@ -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
diff --git a/src/backend/src/routers/save_account.js b/src/backend/src/routers/save_account.js
index 5d4cb342c..060101294 100644
--- a/src/backend/src/routers/save_account.js
+++ b/src/backend/src/routers/save_account.js
@@ -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',
diff --git a/src/backend/src/routers/send-confirm-email.js b/src/backend/src/routers/send-confirm-email.js
index 9816ca499..11635d159 100644
--- a/src/backend/src/routers/send-confirm-email.js
+++ b/src/backend/src/routers/send-confirm-email.js
@@ -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;
\ No newline at end of file
+module.exports = router;
diff --git a/src/backend/src/routers/send-pass-recovery-email.js b/src/backend/src/routers/send-pass-recovery-email.js
index d2fc83906..0ca320713 100644
--- a/src/backend/src/routers/send-pass-recovery-email.js
+++ b/src/backend/src/routers/send-pass-recovery-email.js
@@ -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;
\ No newline at end of file
+module.exports = router;
diff --git a/src/backend/src/routers/set-desktop-bg.js b/src/backend/src/routers/set-desktop-bg.js
index 8a114eb2f..7e75f869d 100644
--- a/src/backend/src/routers/set-desktop-bg.js
+++ b/src/backend/src/routers/set-desktop-bg.js
@@ -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;
\ No newline at end of file
+module.exports = router;
diff --git a/src/backend/src/routers/set-pass-using-token.js b/src/backend/src/routers/set-pass-using-token.js
index ad0e5f9a6..ebc685bc3 100644
--- a/src/backend/src/routers/set-pass-using-token.js
+++ b/src/backend/src/routers/set-pass-using-token.js
@@ -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;
\ No newline at end of file
+module.exports = router;
diff --git a/src/backend/src/routers/signup.js b/src/backend/src/routers/signup.js
index 0e96ee4e2..dcdab350e 100644
--- a/src/backend/src/routers/signup.js
+++ b/src/backend/src/routers/signup.js
@@ -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
diff --git a/src/backend/src/routers/update-taskbar-items.js b/src/backend/src/routers/update-taskbar-items.js
index d09b97246..2d9df1880 100644
--- a/src/backend/src/routers/update-taskbar-items.js
+++ b/src/backend/src/routers/update-taskbar-items.js
@@ -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;
\ No newline at end of file
+module.exports = router;
diff --git a/src/backend/src/routers/user-protected/change-email.js b/src/backend/src/routers/user-protected/change-email.js
index e4a624bc2..b27527236 100644
--- a/src/backend/src/routers/user-protected/change-email.js
+++ b/src/backend/src/routers/user-protected/change-email.js
@@ -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` ' +
diff --git a/src/backend/src/routers/user-protected/change-password.js b/src/backend/src/routers/user-protected/change-password.js
index 2dd0843f1..cdb2b528f 100644
--- a/src/backend/src/routers/user-protected/change-password.js
+++ b/src/backend/src/routers/user-protected/change-password.js
@@ -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
diff --git a/src/backend/src/routers/user-protected/disable-2fa.js b/src/backend/src/routers/user-protected/disable-2fa.js
index 3e4e81eec..0beb514e3 100644
--- a/src/backend/src/routers/user-protected/disable-2fa.js
+++ b/src/backend/src/routers/user-protected/disable-2fa.js
@@ -17,14 +17,16 @@
* along with this program. If not, see .
*/
const { DB_WRITE } = require('../../services/database/consts');
+const { invalidate_cached_user_by_id } = require('../../helpers');
module.exports = {
route: '/disable-2fa',
methods: ['POST'],
- handler: async (req, res, 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;
diff --git a/src/backend/src/services/BaseService.d.ts b/src/backend/src/services/BaseService.d.ts
index 65554c240..052b19895 100644
--- a/src/backend/src/services/BaseService.d.ts
+++ b/src/backend/src/services/BaseService.d.ts
@@ -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';
diff --git a/src/backend/src/services/GetUserService.js b/src/backend/src/services/GetUserService.js
index b217dbf8f..b56bb5691 100644
--- a/src/backend/src/services/GetUserService.js
+++ b/src/backend/src/services/GetUserService.js
@@ -16,11 +16,11 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
-const { redisClient } = require('../clients/redis/redisSingleton');
const { 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);
}
diff --git a/src/backend/src/services/PuterAPIService.js b/src/backend/src/services/PuterAPIService.js
index 612a40a55..a2e0025ac 100644
--- a/src/backend/src/services/PuterAPIService.js
+++ b/src/backend/src/services/PuterAPIService.js
@@ -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'));
diff --git a/src/backend/src/services/ReferralCodeService.js b/src/backend/src/services/ReferralCodeService.js
index 7a9e9d824..7eaf2ae66 100644
--- a/src/backend/src/services/ReferralCodeService.js
+++ b/src/backend/src/services/ReferralCodeService.js
@@ -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;
diff --git a/src/backend/src/services/SessionService.js b/src/backend/src/services/SessionService.js
index ac2eca788..975cb2dce 100644
--- a/src/backend/src/services/SessionService.js
+++ b/src/backend/src/services/SessionService.js
@@ -17,6 +17,7 @@
* along with this program. If not, see .
*/
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
diff --git a/src/backend/src/services/UserRedisCacheSpace.js b/src/backend/src/services/UserRedisCacheSpace.js
new file mode 100644
index 000000000..d0c6283b6
--- /dev/null
+++ b/src/backend/src/services/UserRedisCacheSpace.js
@@ -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 .
+ */
+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 };
diff --git a/src/backend/src/services/UserService.js b/src/backend/src/services/UserService.js
index 217505c32..c5ae78863 100644
--- a/src/backend/src/services/UserService.js
+++ b/src/backend/src/services/UserService.js
@@ -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);
+ }
}
}
diff --git a/src/backend/src/services/ai/chat/AIChatRedisCacheSpace.ts b/src/backend/src/services/ai/chat/AIChatRedisCacheSpace.ts
new file mode 100644
index 000000000..83835a1c8
--- /dev/null
+++ b/src/backend/src/services/ai/chat/AIChatRedisCacheSpace.ts
@@ -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 .
+ */
+
+export const fallbackModelsKey = (modelId: string) => `aichat:fallbacks:${modelId}`;
diff --git a/src/backend/src/services/ai/chat/AIChatService.ts b/src/backend/src/services/ai/chat/AIChatService.ts
index df2963ffa..f609c5420 100644
--- a/src/backend/src/services/ai/chat/AIChatService.ts
+++ b/src/backend/src/services/ai/chat/AIChatService.ts
@@ -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;
}
diff --git a/src/backend/src/services/ai/tts/AWSPollyService.js b/src/backend/src/services/ai/tts/AWSPollyService.js
index 7d270dfda..65821e9a2 100644
--- a/src/backend/src/services/ai/tts/AWSPollyService.js
+++ b/src/backend/src/services/ai/tts/AWSPollyService.js
@@ -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;
}
diff --git a/src/backend/src/services/ai/tts/PollyRedisCacheKeys.js b/src/backend/src/services/ai/tts/PollyRedisCacheKeys.js
new file mode 100644
index 000000000..377ae51ed
--- /dev/null
+++ b/src/backend/src/services/ai/tts/PollyRedisCacheKeys.js
@@ -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 .
+ */
+const PollyRedisCacheKeys = {
+ voices: 'svc:polly:voices',
+};
+
+export { PollyRedisCacheKeys };
diff --git a/src/backend/src/services/auth/GroupRedisCacheSpace.js b/src/backend/src/services/auth/GroupRedisCacheSpace.js
new file mode 100644
index 000000000..26c4debec
--- /dev/null
+++ b/src/backend/src/services/auth/GroupRedisCacheSpace.js
@@ -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 .
+ */
+const GroupRedisCacheSpace = {
+ publicGroupsKey: kvKey => `${kvKey}:public-groups`,
+};
+
+export { GroupRedisCacheSpace };
diff --git a/src/backend/src/services/auth/GroupService.js b/src/backend/src/services/auth/GroupService.js
index e3a02b4db..94bbb59c4 100644
--- a/src/backend/src/services/auth/GroupService.js
+++ b/src/backend/src/services/auth/GroupService.js
@@ -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;
}
diff --git a/src/backend/src/services/auth/PermissionScanRedisCacheSpace.js b/src/backend/src/services/auth/PermissionScanRedisCacheSpace.js
new file mode 100644
index 000000000..a8aca78e0
--- /dev/null
+++ b/src/backend/src/services/auth/PermissionScanRedisCacheSpace.js
@@ -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 .
+ */
+const PermissionScanRedisCacheSpace = {
+ key: ({ actorUid, permissionOptions, joinPermissionParts }) => (
+ joinPermissionParts('permission-scan', actorUid, 'options-list', ...permissionOptions)
+ ),
+};
+
+export { PermissionScanRedisCacheSpace };
diff --git a/src/backend/src/services/auth/PermissionService.js b/src/backend/src/services/auth/PermissionService.js
index 75b7f6e2d..bf1d0f9c3 100644
--- a/src/backend/src/services/auth/PermissionService.js
+++ b/src/backend/src/services/auth/PermissionService.js
@@ -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;
}
diff --git a/src/backend/src/services/sla/RateLimitRedisCacheSpace.js b/src/backend/src/services/sla/RateLimitRedisCacheSpace.js
new file mode 100644
index 000000000..d14844281
--- /dev/null
+++ b/src/backend/src/services/sla/RateLimitRedisCacheSpace.js
@@ -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 .
+ */
+const RateLimitRedisCacheSpace = {
+ keyPrefix: consumerScopedKey => `rate-limit:${consumerScopedKey}`,
+ windowStartKey: consumerScopedKey => `${RateLimitRedisCacheSpace.keyPrefix(consumerScopedKey)}:window_start`,
+ countKey: consumerScopedKey => `${RateLimitRedisCacheSpace.keyPrefix(consumerScopedKey)}:count`,
+};
+
+export { RateLimitRedisCacheSpace };
diff --git a/src/backend/src/services/sla/RateLimitService.js b/src/backend/src/services/sla/RateLimitService.js
index 6915ccbf7..324d15669 100644
--- a/src/backend/src/services/sla/RateLimitService.js
+++ b/src/backend/src/services/sla/RateLimitService.js
@@ -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]);
}
diff --git a/src/backend/src/validation.js b/src/backend/src/validation.js
index 4fef67aed..5deac66cd 100644
--- a/src/backend/src/validation.js
+++ b/src/backend/src/validation.js
@@ -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,
-};
+};
\ No newline at end of file