diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.js b/src/backend/src/routers/hosting/puterSiteMiddleware.js index 0baea610bc..7695e8703f 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.js @@ -331,53 +331,6 @@ function getPrivateAccessRejectionReason (error) { return error?.code || error?.message || 'unknown'; } -function stripBootstrapAuthTokenFromOriginalUrl (originalUrl) { - if ( typeof originalUrl !== 'string' || !originalUrl ) return null; - - try { - const placeholderOrigin = 'https://placeholder.puter.local'; - const parsedUrl = new URL(originalUrl, placeholderOrigin); - const hadToken = - parsedUrl.searchParams.has('puter.auth.token') - || parsedUrl.searchParams.has('auth_token'); - if ( ! hadToken ) return null; - - parsedUrl.searchParams.delete('puter.auth.token'); - parsedUrl.searchParams.delete('auth_token'); - - const search = parsedUrl.searchParams.toString(); - const cleanPath = parsedUrl.pathname || '/'; - return search ? `${cleanPath}?${search}` : cleanPath; - } catch { - return null; - } -} - -function hasAppInstanceIdQueryParam (req) { - const queryParamCandidates = [ - req.query?.['puter.app_instance_id'], - req.query?.puter?.app_instance_id, - ]; - for ( const queryParamCandidate of queryParamCandidates ) { - if ( typeof queryParamCandidate === 'string' && queryParamCandidate.trim() ) { - return true; - } - } - - if ( typeof req.originalUrl !== 'string' || !req.originalUrl ) { - return false; - } - - try { - const placeholderOrigin = 'https://placeholder.puter.local'; - const parsedUrl = new URL(req.originalUrl, placeholderOrigin); - const appInstanceId = parsedUrl.searchParams.get('puter.app_instance_id'); - return typeof appInstanceId === 'string' && !!appInstanceId.trim(); - } catch { - return false; - } -} - function getTokenFromAuthorizationHeader (req) { const authorizationHeader = req.headers?.authorization; if ( typeof authorizationHeader !== 'string' ) return null; @@ -920,18 +873,6 @@ async function evaluatePublicHostedActorContext ({ return true; } - const sanitizedUrl = stripBootstrapAuthTokenFromOriginalUrl(req.originalUrl); - if ( sanitizedUrl ) { - logPrivateAccessEvent('public_actor.cookie_redirect', { - appUid: tokenAppUid ?? null, - userUid: identity.userUid ?? null, - requestHost: req.hostname, - redirectUrl: sanitizedUrl, - }); - res.redirect(sanitizedUrl); - return false; - } - return true; } @@ -1238,30 +1179,6 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath }), ); - const sanitizedUrl = stripBootstrapAuthTokenFromOriginalUrl(req.originalUrl); - const shouldKeepBootstrapTokenInUrl = hasAppInstanceIdQueryParam(req); - if ( sanitizedUrl && !shouldKeepBootstrapTokenInUrl ) { - logPrivateAccessEvent('private_access.allowed_cookie_redirect', { - appUid: app.uid, - userUid: identity.userUid ?? null, - requestHost: req.hostname, - requestPath, - source: identity.source, - redirectUrl: sanitizedUrl, - }); - res.redirect(sanitizedUrl); - return false; - } - if ( sanitizedUrl && shouldKeepBootstrapTokenInUrl ) { - logPrivateAccessEvent('private_access.allowed_cookie_redirect_skipped_for_app_instance', { - appUid: app.uid, - userUid: identity.userUid ?? null, - requestHost: req.hostname, - requestPath, - source: identity.source, - redirectUrl: sanitizedUrl, - }); - } } logPrivateAccessEvent('private_access.allowed', { diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js index 26843eef3a..4337ad87e9 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js @@ -1135,7 +1135,7 @@ describe('PuterSiteMiddleware', () => { 'private-token', { sameSite: 'none' }, ); - expect(mockRes.redirect).toHaveBeenCalledWith('/asset.js?foo=bar'); + expect(mockRes.redirect).not.toHaveBeenCalled(); expect(mockNext).not.toHaveBeenCalled(); }); @@ -1729,7 +1729,7 @@ describe('PuterSiteMiddleware', () => { expect(mockNext).not.toHaveBeenCalled(); }); - it('sets public hosted cookie and redirects to sanitized url for bootstrap tokens', async () => { + it('sets public hosted cookie for bootstrap tokens without server-side token stripping redirect', async () => { const { rootDirectoryNode, missingFileNode } = createRootAndMissingNodes(); let filesystemNodeCallCount = 0; const authService = { @@ -1835,7 +1835,7 @@ describe('PuterSiteMiddleware', () => { 'public-hosted-token-333', { sameSite: 'none' }, ); - expect(mockRes.redirect).toHaveBeenCalledWith('/asset.js?foo=bar'); + expect(mockRes.redirect).not.toHaveBeenCalled(); expect(mockNext).not.toHaveBeenCalled(); }); @@ -1942,7 +1942,7 @@ describe('PuterSiteMiddleware', () => { subdomain: 'paid', host: 'paid.site.puter.localhost', }); - expect(mockRes.redirect).toHaveBeenCalledWith('/asset.js?foo=bar'); + expect(mockRes.redirect).not.toHaveBeenCalled(); expect(mockNext).not.toHaveBeenCalled(); }); diff --git a/src/backend/src/services/BaseService.d.ts b/src/backend/src/services/BaseService.d.ts index acffc87b1d..39bc0a54c3 100644 --- a/src/backend/src/services/BaseService.d.ts +++ b/src/backend/src/services/BaseService.d.ts @@ -19,6 +19,7 @@ import type { MeteringServiceWrapper } from './MeteringService/MeteringServiceWr import type { SUService } from './SUService'; import type { UserService } from './UserService'; import { TokenService } from './auth/TokenService'; +import { SessionService } from './SessionService'; export interface ServicesMap { su: SUService; @@ -41,7 +42,8 @@ export interface ServicesMap { 'clean-email': CleanEmailService; 'error-service': ErrorService; driver: DriverService; - 'token': TokenService + 'token': TokenService; + 'session': SessionService; } export interface ServiceResources { diff --git a/src/backend/src/services/SessionService.js b/src/backend/src/services/SessionService.js index 3b8c5d625c..fde0a9adcf 100644 --- a/src/backend/src/services/SessionService.js +++ b/src/backend/src/services/SessionService.js @@ -19,14 +19,18 @@ const { redisClient } = require('../clients/redis/redisSingleton'); const { UserRedisCacheSpace } = require('./UserRedisCacheSpace.js'); const { get_user } = require('../helpers'); -const { asyncSafeSetInterval } = require('@heyputer/putility').libs.promise; const { v4: uuidv4 } = require('uuid'); const SECOND = 1000; -const MINUTE = 60 * SECOND; -const BaseService = require('./BaseService'); -const { DB_WRITE } = require('./database/consts'); +const { BaseService } = require('./BaseService'); const SESSION_CACHE_TTL_SECONDS = 5 * 60; const SESSION_CACHE_KEY_PREFIX = 'session-cache'; +const SESSION_FLUSH_PENDING_SET_KEY = `${SESSION_CACHE_KEY_PREFIX}:flush-pending`; +const SESSION_USER_SESSIONS_KEY_PREFIX = `${SESSION_CACHE_KEY_PREFIX}:user-sessions`; +const SESSION_FLUSH_LOCK_KEY_PREFIX = `${SESSION_CACHE_KEY_PREFIX}:flush-lock`; +const SESSION_FLUSH_LOCK_TTL_SECONDS = 30; +const SESSION_FLUSH_INTERVAL_STEP_SECONDS = 5; +const SESSION_FLUSH_INTERVAL_MIN_STEPS = 1; +const SESSION_FLUSH_INTERVAL_MAX_STEPS = 12; /** * This service is responsible for updating session activity @@ -44,26 +48,80 @@ const SESSION_CACHE_KEY_PREFIX = 'session-cache'; * - Provides methods to interact with sessions, including session creation, retrieval, and termination. */ class SessionService extends BaseService { - _construct () { - this.sessions = {}; - } - getSessionCacheKey (uuid) { return `${SESSION_CACHE_KEY_PREFIX}:${uuid}`; } - async cacheSession (session) { + getSessionUserSetKey (userId) { + return `${SESSION_USER_SESSIONS_KEY_PREFIX}:${userId}`; + } + + getSessionFlushLockKey (uuid) { + return `${SESSION_FLUSH_LOCK_KEY_PREFIX}:${uuid}`; + } + + #getRandomFlushIntervalMs () { + const randomSteps = + Math.floor( + Math.random() * ( + SESSION_FLUSH_INTERVAL_MAX_STEPS + - SESSION_FLUSH_INTERVAL_MIN_STEPS + + 1 + ), + ) + SESSION_FLUSH_INTERVAL_MIN_STEPS; + return randomSteps * SESSION_FLUSH_INTERVAL_STEP_SECONDS * SECOND; + } + + #scheduleSessionFlushLoop () { + setTimeout(async () => { + try { + await this.#updateSessions(); + } catch (e) { + console.warn('session flush loop failed', { + reason: e?.message || String(e), + }); + } + this.#scheduleSessionFlushLoop(); + }, this.#getRandomFlushIntervalMs()); + } + + async cacheSession (session, options = {}) { if ( ! session?.uuid ) return; + const flushState = options.flushState || 'unchanged'; + const normalizedSession = { + ...session, + flushPending: + flushState === 'pending' + ? true + : ( + flushState === 'flushed' + ? false + : !!session.flushPending + ), + }; try { await redisClient.set( - this.getSessionCacheKey(session.uuid), - JSON.stringify(session), + this.getSessionCacheKey(normalizedSession.uuid), + JSON.stringify(normalizedSession), 'EX', SESSION_CACHE_TTL_SECONDS, ); + + if ( normalizedSession.user_id ) { + const userSessionSetKey = + this.getSessionUserSetKey(normalizedSession.user_id); + await redisClient.sadd(userSessionSetKey, normalizedSession.uuid); + await redisClient.expire(userSessionSetKey, SESSION_CACHE_TTL_SECONDS); + } + + if ( flushState === 'pending' ) { + await redisClient.sadd(SESSION_FLUSH_PENDING_SET_KEY, normalizedSession.uuid); + } else if ( flushState === 'flushed' ) { + await redisClient.srem(SESSION_FLUSH_PENDING_SET_KEY, normalizedSession.uuid); + } } catch (e) { - this.log.warn('failed to cache session in redis', { - uuid: session.uuid, + console.warn('failed to cache session in redis', { + uuid: normalizedSession.uuid, reason: e?.message || String(e), }); } @@ -74,7 +132,7 @@ class SessionService extends BaseService { try { cachedSessionRaw = await redisClient.get(this.getSessionCacheKey(uuid)); } catch (e) { - this.log.warn('failed to read session from redis', { + console.warn('failed to read session from redis', { uuid, reason: e?.message || String(e), }); @@ -94,11 +152,17 @@ class SessionService extends BaseService { } } - async invalidateCachedSession (uuid) { + async invalidateCachedSession (uuid, userId) { try { - await redisClient.del(this.getSessionCacheKey(uuid)); + await redisClient.del( + this.getSessionCacheKey(uuid), + ); + await redisClient.srem(SESSION_FLUSH_PENDING_SET_KEY, uuid); + if ( userId ) { + await redisClient.srem(this.getSessionUserSetKey(userId), uuid); + } } catch (e) { - this.log.warn('failed to delete cached session from redis', { + console.warn('failed to delete cached session from redis', { uuid, reason: e?.message || String(e), }); @@ -114,25 +178,8 @@ class SessionService extends BaseService { * @method _init */ async _init () { - this.db = await this.services.get('database').get(DB_WRITE, 'session'); - - (async () => { - // TODO: change to 5 minutes or configured value - /** - * Initializes periodic session updates. - * - * This method sets up an interval to call `_update_sessions` every 2 minutes. - * - * @memberof SessionService - * @private - * @async - * @param {none} - No parameters are required. - * @returns {Promise} - Resolves when the interval is set. - */ - asyncSafeSetInterval(async () => { - await this._update_sessions(); - }, 2 * MINUTE); - })(); + this.db = await this.services.get('database').get(); + this.#scheduleSessionFlushLoop(); } /** @@ -165,8 +212,8 @@ class SessionService extends BaseService { user_uid: user.uuid, user_id: user.id, meta, + flushPending: false, }; - this.sessions[uuid] = session; await this.cacheSession(session); return session; @@ -179,15 +226,13 @@ class SessionService extends BaseService { * @param {string} uuid - The UUID of the session to retrieve. * @returns {Object|undefined} The session object with internal values removed, or undefined if the session does not exist. */ - async get_session_ (uuid) { + async #getSession (uuid) { let session = await this.getCachedSession(uuid); if ( session ) { session.last_touch = Date.now(); - this.sessions[uuid] = session; - await this.cacheSession(session); return session; } - ;[session] = await this.db.read( + ;[session] = await this.db.tryHardRead( 'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1', [uuid], ); @@ -204,8 +249,6 @@ class SessionService extends BaseService { })(); const user = await get_user({ id: session.user_id }); session.user_uid = user?.uuid; - this.sessions[uuid] = session; - await this.cacheSession(session); return session; } /** @@ -213,17 +256,22 @@ class SessionService extends BaseService { * @param {string} uuid - The unique identifier for the session to retrieve. * @returns {Promise} The session object with internal values removed, or undefined if not found. */ - async get_session (uuid) { - const session = await this.get_session_(uuid); + async getSession (uuid) { + const session = await this.#getSession(uuid); if ( session ) { session.last_touch = Date.now(); - session.meta.last_activity = (new Date()).toISOString(); - await this.cacheSession(session); + session.meta = { + ...(session.meta || {}), + last_activity: (new Date()).toISOString(), + }; + await this.cacheSession(session, { + flushState: 'pending', + }); } - return this.remove_internal_values_(session); + return this.#removeInternalValues(session); } - remove_internal_values_ (session) { + #removeInternalValues (session) { if ( session === undefined ) return; const copy = { @@ -232,86 +280,182 @@ class SessionService extends BaseService { delete copy.last_touch; delete copy.last_store; delete copy.user_id; + delete copy.flushPending; return copy; } - get_user_sessions (user) { + async get_user_sessions (user) { + if ( ! user?.id ) return []; + + let sessionUuids; + try { + sessionUuids = await redisClient.smembers( + this.getSessionUserSetKey(user.id), + ); + } catch (e) { + console.warn('failed to read user session set from redis', { + userId: user.id, + reason: e?.message || String(e), + }); + return []; + } + + if ( !Array.isArray(sessionUuids) || sessionUuids.length === 0 ) { + return []; + } + const sessions = []; - for ( const session of Object.values(this.sessions) ) { - if ( session.user_id === user.id ) { - sessions.push(session); + for ( const sessionUuid of sessionUuids ) { + const session = await this.getCachedSession(sessionUuid); + if ( !session || session.user_id !== user.id ) { + await redisClient.srem(this.getSessionUserSetKey(user.id), sessionUuid); + continue; } + sessions.push(session); } - return sessions.map(this.remove_internal_values_.bind(this)); + + return sessions.map(this.#removeInternalValues.bind(this)); } /** - * Removes a session from the in-memory cache and the database. + * Removes a session from Redis-backed cache state and the database. * * @param {string} uuid - The UUID of the session to remove. * @returns {Promise} A promise that resolves to the result of the database write operation. */ async remove_session (uuid) { - delete this.sessions[uuid]; - await this.invalidateCachedSession(uuid); + const cachedSession = await this.getCachedSession(uuid); + const [dbSession] = await this.db.tryHardRead( + 'SELECT `user_id` FROM `sessions` WHERE `uuid` = ? LIMIT 1', + [uuid], + ); + await this.invalidateCachedSession(uuid, cachedSession?.user_id ?? dbSession?.user_id); return await this.db.write( 'DELETE FROM `sessions` WHERE `uuid` = ?', [uuid], ); } - async _update_sessions () { - this.log.tick('UPDATING SESSIONS'); + async #updateSessions () { const now = Date.now(); - const keys = Object.keys(this.sessions); + let pendingSessionUuids; + try { + pendingSessionUuids = await redisClient.smembers(SESSION_FLUSH_PENDING_SET_KEY); + } catch (e) { + console.warn('failed to read pending session flush set from redis', { + reason: e?.message || String(e), + }); + return; + } + if ( !Array.isArray(pendingSessionUuids) || pendingSessionUuids.length === 0 ) { + return; + } - const user_updates = {}; + const userUpdates = {}; + + for ( const sessionUuid of pendingSessionUuids ) { + const lockKey = this.getSessionFlushLockKey(sessionUuid); + let lockAcquired = false; + try { + lockAcquired = await redisClient.set( + lockKey, + '1', + 'EX', + SESSION_FLUSH_LOCK_TTL_SECONDS, + 'NX', + ); + if ( ! lockAcquired ) continue; + + const session = await this.getCachedSession(sessionUuid); + if ( ! session ) { + await redisClient.srem(SESSION_FLUSH_PENDING_SET_KEY, sessionUuid); + continue; + } + if ( ! session.flushPending ) { + await redisClient.srem(SESSION_FLUSH_PENDING_SET_KEY, sessionUuid); + continue; + } + + const lastTouch = typeof session.last_touch === 'number' + ? session.last_touch + : now; + const unixTs = Math.floor(lastTouch / 1000); + session.meta = { + ...(session.meta || {}), + last_activity: (new Date(lastTouch)).toISOString(), + }; - for ( const key of keys ) { - const session = this.sessions[key]; - if ( now - session.last_store > 5 * MINUTE ) { - this.log.debug(`storing session meta: ${ session.uuid}`); - const unix_ts = Math.floor(now / 1000); const { anyRowsAffected } = await this.db.write( 'UPDATE `sessions` ' + 'SET `meta` = ?, `last_activity` = ? ' + - 'WHERE `uuid` = ?', - [JSON.stringify(session.meta), unix_ts, session.uuid], + 'WHERE `uuid` = ? AND (`last_activity` IS NULL OR `last_activity` < ?)', + [JSON.stringify(session.meta), unixTs, session.uuid, unixTs], ); if ( ! anyRowsAffected ) { - delete this.sessions[key]; - continue; + const [existingSession] = await this.db.tryHardRead( + 'SELECT `uuid` FROM `sessions` WHERE `uuid` = ? LIMIT 1', + [session.uuid], + ); + if ( ! existingSession ) { + await this.invalidateCachedSession(session.uuid, session.user_id); + continue; + } } session.last_store = now; + await this.cacheSession({ + ...session, + flushPending: false, + }, { + flushState: 'flushed', + }); + if ( - !user_updates[session.user_id] || - user_updates[session.user_id][1] < session.last_touch + session.user_id && + ( + !userUpdates[session.user_id] + || userUpdates[session.user_id] < lastTouch + ) ) { - user_updates[session.user_id] = [session.user_id, session.last_touch]; + userUpdates[session.user_id] = lastTouch; + } + } catch (e) { + console.warn('failed to flush session update to db', { + uuid: sessionUuid, + reason: e?.message || String(e), + }); + } finally { + if ( lockAcquired ) { + await redisClient.del(lockKey); } } } - for ( const [user_id, last_touch] of Object.values(user_updates) ) { + for ( const [userIdRaw, lastTouch] of Object.entries(userUpdates) ) { + const userId = Number(userIdRaw); const sql_ts = (date => `${date.toISOString().split('T')[0] } ${ date.toTimeString().split(' ')[0]}` - )(new Date(last_touch)); + )(new Date(lastTouch)); await this.db.write( 'UPDATE `user` ' + 'SET `last_activity_ts` = ? ' + - 'WHERE `id` = ? LIMIT 1', - [sql_ts, user_id], + 'WHERE `id` = ? AND (`last_activity_ts` IS NULL OR `last_activity_ts` < ?) LIMIT 1', + [sql_ts, userId, sql_ts], ); - const cachedUser = await redisClient.get(UserRedisCacheSpace.key('id', user_id)); + const cachedUser = await redisClient.get(UserRedisCacheSpace.key('id', userId)); if ( cachedUser ) { try { const user = JSON.parse(cachedUser); - user.last_activity_ts = sql_ts; - UserRedisCacheSpace.setUser(user); + if ( + !user.last_activity_ts || + user.last_activity_ts < sql_ts + ) { + user.last_activity_ts = sql_ts; + UserRedisCacheSpace.setUser(user); + } } catch ( e ) { console.warn(e); // ignore malformed cache entries diff --git a/src/backend/src/services/SessionService.test.js b/src/backend/src/services/SessionService.test.js index 46f7fb22ac..c5ee06e9d4 100644 --- a/src/backend/src/services/SessionService.test.js +++ b/src/backend/src/services/SessionService.test.js @@ -1,123 +1,123 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { createTestKernel } from '../../tools/test.mjs'; import { SessionService } from './SessionService.js'; import { tmp_provide_services } from '../helpers.js'; import { redisClient } from '../clients/redis/redisSingleton.js'; -describe('SessionService', () => { - let getUserMock; - const cachedSessionUuid = 'session-11111111-1111-1111-1111-111111111111'; - - const createSessionService = () => { - const sessionService = Object.create(SessionService.prototype); - sessionService.sessions = {}; - sessionService.log = { - warn: vi.fn(), - tick: vi.fn(), - debug: vi.fn(), - }; - return sessionService; - }; - - beforeEach(async () => { - getUserMock = vi.fn().mockResolvedValue({ - uuid: 'user-11111111-1111-1111-1111-111111111111', - }); - await tmp_provide_services({ - ready: Promise.resolve(), - get: (serviceName) => { - if ( serviceName === 'get-user' ) { - return { - get_user: getUserMock, - }; - } - throw new Error(`unexpected service lookup: ${serviceName}`); +describe('SessionService', async () => { + const testKernel = await createTestKernel({ + initLevelString: 'init', + testCore: true, + serviceMap: { + session: SessionService, + }, + serviceConfigOverrideMap: { + database: { + path: ':memory:', }, - }); + }, }); - afterEach(async () => { - await redisClient.del(`session-cache:${cachedSessionUuid}`); - }); + await tmp_provide_services(testKernel.services); - it('caches sessions in redis on create with five-minute ttl', async () => { - const sessionService = createSessionService(); - sessionService.db = { - write: vi.fn().mockResolvedValue({}), - }; - sessionService.getSessionCacheKey = vi.fn().mockReturnValue(`session-cache:${cachedSessionUuid}`); + const sessionService = testKernel.services.get('session'); + const db = testKernel.services.get('database').get('write', 'session-test'); - const session = await sessionService.create_session({ - id: 42, - uuid: 'user-11111111-1111-1111-1111-111111111111', - }, {}); + const makeUnique = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const cacheKey = sessionService.getSessionCacheKey.mock.results[0].value; - const cached = await redisClient.get(cacheKey); - expect(cached).toBeTruthy(); - expect(JSON.parse(cached).uuid).toBe(session.uuid); - expect(await redisClient.ttl(cacheKey)).toBeGreaterThan(0); - expect(await redisClient.ttl(cacheKey)).toBeLessThanOrEqual(300); - }); + const createUser = async () => { + const userUuid = makeUnique('user'); + const username = makeUnique('session-user'); + await db.write( + 'INSERT INTO `user` (`uuid`, `username`) VALUES (?, ?)', + [userUuid, username], + ); + const [user] = await db.read( + 'SELECT * FROM `user` WHERE `uuid` = ? LIMIT 1', + [userUuid], + ); + return user; + }; - it('loads sessions from redis cache before db on read', async () => { - const sessionService = createSessionService(); - sessionService.db = { - read: vi.fn(), - case: ({ mysql }) => mysql, - }; - const cachedSession = { - uuid: cachedSessionUuid, - user_id: 42, - user_uid: 'user-11111111-1111-1111-1111-111111111111', - meta: {}, - last_touch: Date.now(), - last_store: Date.now(), - }; - await sessionService.cacheSession(cachedSession); + const clearSessionState = async (sessionUuid, userId) => { + if ( sessionUuid ) { + await redisClient.del(sessionService.getSessionCacheKey(sessionUuid)); + await redisClient.srem('session-cache:flush-pending', sessionUuid); + if ( userId ) { + await redisClient.srem( + sessionService.getSessionUserSetKey(userId), + sessionUuid, + ); + } + await db.write('DELETE FROM `sessions` WHERE `uuid` = ?', [sessionUuid]); + } + }; - const session = await sessionService.get_session_(cachedSessionUuid); + it('caches sessions in redis on create with five-minute ttl', async () => { + const user = await createUser(); + const session = await sessionService.create_session(user, {}); + try { + const cacheKey = sessionService.getSessionCacheKey(session.uuid); + const cached = await redisClient.get(cacheKey); + expect(cached).toBeTruthy(); + expect(JSON.parse(cached).uuid).toBe(session.uuid); + expect(await redisClient.ttl(cacheKey)).toBeGreaterThan(0); + expect(await redisClient.ttl(cacheKey)).toBeLessThanOrEqual(300); + const cachedUserSessionUuids = await redisClient.smembers( + sessionService.getSessionUserSetKey(user.id), + ); + expect(cachedUserSessionUuids).toContain(session.uuid); + } finally { + await clearSessionState(session.uuid, user.id); + } + }); - expect(sessionService.db.read).not.toHaveBeenCalled(); - expect(session.user_uid).toBe('user-11111111-1111-1111-1111-111111111111'); + it('loads sessions from redis cache before db on read', async () => { + const user = await createUser(); + const session = await sessionService.create_session(user, {}); + const dbReadSpy = vi.spyOn(sessionService.db, 'tryHardRead'); + try { + const loaded = await sessionService.getSession(session.uuid); + expect(dbReadSpy).not.toHaveBeenCalled(); + expect(loaded.user_uid).toBe(user.uuid); + const pendingSessions = await redisClient.smembers('session-cache:flush-pending'); + expect(pendingSessions).toContain(session.uuid); + } finally { + dbReadSpy.mockRestore(); + await clearSessionState(session.uuid, user.id); + } }); it('invalidates redis cache when removing session', async () => { - const sessionService = createSessionService(); - sessionService.db = { - write: vi.fn().mockResolvedValue({ anyRowsAffected: true }), - }; - await sessionService.cacheSession({ - uuid: cachedSessionUuid, - user_id: 42, - user_uid: 'user-11111111-1111-1111-1111-111111111111', - meta: {}, - last_touch: Date.now(), - last_store: Date.now(), - }); + const user = await createUser(); + const session = await sessionService.create_session(user, {}); + await sessionService.remove_session(session.uuid); - await sessionService.remove_session(cachedSessionUuid); - - expect(await redisClient.get(`session-cache:${cachedSessionUuid}`)).toBeNull(); - expect(sessionService.db.write).toHaveBeenCalledWith( - 'DELETE FROM `sessions` WHERE `uuid` = ?', - [cachedSessionUuid], + const [dbSession] = await db.read( + 'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1', + [session.uuid], + ); + expect(await redisClient.get(`session-cache:${session.uuid}`)).toBeNull(); + expect(dbSession).toBeUndefined(); + const pendingSessions = await redisClient.smembers('session-cache:flush-pending'); + expect(pendingSessions).not.toContain(session.uuid); + const cachedUserSessionUuids = await redisClient.smembers( + sessionService.getSessionUserSetKey(user.id), ); + expect(cachedUserSessionUuids).not.toContain(session.uuid); }); it('loads session user uid using object lookup options', async () => { - const sessionService = createSessionService(); - sessionService.db = { - read: vi.fn().mockResolvedValue([{ - uuid: cachedSessionUuid, - user_id: 42, - meta: '{}', - }]), - case: ({ mysql }) => mysql, - }; + const user = await createUser(); + const session = await sessionService.create_session(user, {}); - const session = await sessionService.get_session_(cachedSessionUuid); + await redisClient.del(`session-cache:${session.uuid}`); - expect(getUserMock).toHaveBeenCalledWith({ id: 42 }); - expect(session.user_uid).toBe('user-11111111-1111-1111-1111-111111111111'); + const loadedSession = await sessionService.getSession(session.uuid); + try { + expect(loadedSession.user_uid).toBe(user.uuid); + } finally { + await clearSessionState(session.uuid, user.id); + } }); }); diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index 4179b1850b..9ff7e8cb7b 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -50,7 +50,7 @@ class AuthService extends BaseService { async _init () { this.db = await this.services.get('database').get(DB_WRITE, 'auth'); - this.svc_session = await this.services.get('session'); + this.sessionService = await this.services.get('session'); const svc_feature_flag = await this.services.get('feature-flag'); svc_feature_flag.register('temp-users-disabled', { @@ -103,7 +103,7 @@ class AuthService extends BaseService { } if ( decoded.type === 'session' ) { - const session = await this.get_session_(decoded.uuid); + const session = await this.sessionService.getSession(decoded.uuid); if ( ! session ) { throw APIError.create('token_auth_failed'); @@ -128,7 +128,7 @@ class AuthService extends BaseService { } if ( decoded.type === 'gui' ) { - const session = await this.get_session_(decoded.uuid); + const session = await this.sessionService.getSession(decoded.uuid); if ( ! session ) { throw APIError.create('token_auth_failed'); @@ -156,7 +156,7 @@ class AuthService extends BaseService { let session; if ( decoded.session ) { const session_uuid = this.uuid_fpe.decrypt(decoded.session); - session = await this.get_session_(session_uuid); + session = await this.sessionService.getSession(session_uuid); if ( ! session ) { throw APIError.create('token_auth_failed'); @@ -799,7 +799,7 @@ class AuthService extends BaseService { throw new Error('Token missing sessionUuid'); } - const session = await this.get_session_(sessionUuid); + const session = await this.sessionService.getSession(sessionUuid); if ( ! session ) { throw new Error('Token missing session'); } @@ -864,15 +864,7 @@ class AuthService extends BaseService { } } - return await this.svc_session.create_session(user, meta); - } - - /** - * Alias to SessionService's get_session method, - * in case AuthService ever needs to wrap this functionality. - */ - async get_session_ (uuid) { - return await this.svc_session.get_session(uuid); + return await this.sessionService.create_session(user, meta); } /** @@ -963,7 +955,7 @@ class AuthService extends BaseService { if ( ! is_legacy ) { // Ensure session exists - const session = await this.get_session_(decoded.uuid); + const session = await this.sessionService.getSession(decoded.uuid); if ( ! session ) { return {}; } @@ -1010,7 +1002,7 @@ class AuthService extends BaseService { return; } - await this.svc_session.remove_session(decoded.uuid); + await this.sessionService.remove_session(decoded.uuid); } /** @@ -1122,7 +1114,7 @@ class AuthService extends BaseService { const seen = new Set(); const sessions = []; - const cache_sessions = this.svc_session.get_user_sessions(actor.type.user); + const cache_sessions = await this.sessionService.get_user_sessions(actor.type.user); for ( const session of cache_sessions ) { seen.add(session.uuid); sessions.push(session); @@ -1170,7 +1162,7 @@ class AuthService extends BaseService { */ async revoke_session (actor, uuid) { delete this.sessions[uuid]; - this.svc_session.remove_session(uuid); + this.sessionService.remove_session(uuid); } /** diff --git a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts index 4c420690fb..fc2189b3a9 100644 --- a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts +++ b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { createTestKernel } from '../../../tools/test.mjs'; +import { tmp_provide_services } from '../../helpers.js'; import * as jwt from 'jsonwebtoken'; import { AuthService } from './AuthService.js'; @@ -15,15 +18,9 @@ type AuthServiceForPrivateTokenTests = AuthService & { private_app_hosting_domain: string; private_app_hosting_domain_alt?: string; }; - modules: { - jwt: { - sign: typeof jwt.sign; - verify: typeof jwt.verify; - }; - }; tokenService: { - sign: typeof jwt.sign; - verify: typeof jwt.verify; + sign: (scope: string, payload: unknown, options?: jwt.SignOptions) => string; + verify: (scope: string, token: string) => jwt.JwtPayload & Record; }; uuid_fpe: { encrypt: (value: string) => string; @@ -32,52 +29,81 @@ type AuthServiceForPrivateTokenTests = AuthService & { services: { get: (name: string) => unknown; }; + sessionService: { + getSession: (uuid: string) => Promise<{ uuid: string; user_uid?: string } | undefined>; + create_session: (user: { id: number; uuid: string }, meta?: Record) => Promise<{ uuid: string }>; + }; appOriginCanonicalizationLocalCacheNamespace?: string; }; -const createAuthService = (): AuthServiceForPrivateTokenTests => { - const authService = Object.create(AuthService.prototype) as AuthServiceForPrivateTokenTests; - authService.global_config = { - jwt_secret: 'private-asset-test-secret', - private_app_asset_token_ttl_seconds: 3600, - private_app_asset_cookie_name: 'puter.private.asset.token', - app_origin_canonical_cache_ttl_seconds: 300, - public_hosted_actor_token_ttl_seconds: 900, - public_hosted_actor_cookie_name: 'puter.public.hosted.actor.token', - static_hosting_domain: 'puter.site', - static_hosting_domain_alt: 'puter.host', - private_app_hosting_domain: 'app.puter.localhost', - private_app_hosting_domain_alt: 'puter.dev', - }; - authService.modules = { - jwt: { - sign: jwt.sign.bind(jwt), - verify: jwt.verify.bind(jwt), +const testKernel = await createTestKernel({ + initLevelString: 'init', + testCore: true, + serviceConfigOverrideMap: { + database: { + path: ':memory:', }, - }; - authService.tokenService = { - sign: (_scope, payload, options) => - jwt.sign(payload as Parameters[0], authService.global_config.jwt_secret, options), - verify: (_scope, token) => - jwt.verify(token, authService.global_config.jwt_secret), - }; - authService.uuid_fpe = { - encrypt: (value) => value, - decrypt: (value) => value, - }; - authService.services = { - get: (_name) => ({ - emit: async () => { - }, - }), - }; - authService.appOriginCanonicalizationLocalCacheNamespace = `test:${Math.random().toString(36).slice(2)}`; - authService.readCanonicalAppUidFromRedisCache = vi.fn().mockResolvedValue(undefined); - authService.writeCanonicalAppUidToRedisCache = vi.fn().mockResolvedValue(undefined); - authService.get_session_ = vi.fn().mockResolvedValue(undefined); + }, +}); +await tmp_provide_services(testKernel.services); + +const authService = testKernel.services.get('auth') as AuthServiceForPrivateTokenTests; +const db = testKernel.services.get('database').get('write', 'auth-private-asset-test'); + +const applyDefaultAuthConfig = () => { + authService.global_config.jwt_secret = 'private-asset-test-secret'; + authService.global_config.private_app_asset_token_ttl_seconds = 3600; + authService.global_config.private_app_asset_cookie_name = 'puter.private.asset.token'; + authService.global_config.app_origin_canonical_cache_ttl_seconds = 300; + authService.global_config.public_hosted_actor_token_ttl_seconds = 900; + authService.global_config.public_hosted_actor_cookie_name = 'puter.public.hosted.actor.token'; + authService.global_config.static_hosting_domain = 'puter.site'; + authService.global_config.static_hosting_domain_alt = 'puter.host'; + authService.global_config.private_app_hosting_domain = 'app.puter.localhost'; + authService.global_config.private_app_hosting_domain_alt = 'puter.dev'; + (authService.tokenService as { secret: string }).secret = authService.global_config.jwt_secret; + authService.appOriginCanonicalizationLocalCacheNamespace = + authService.createAppOriginLocalCacheNamespace(); +}; + +const createAuthService = (): AuthServiceForPrivateTokenTests => { + applyDefaultAuthConfig(); return authService; }; +const insertUser = async () => { + const userUuid = randomUUID(); + const username = `u_${Math.random().toString(36).slice(2, 10)}`; + await db.write( + 'INSERT INTO `user` (`uuid`, `username`) VALUES (?, ?)', + [userUuid, username], + ); + const [user] = await db.read( + 'SELECT * FROM `user` WHERE `uuid` = ? LIMIT 1', + [userUuid], + ); + return user as { id: number; uuid: string; username: string }; +}; + +const insertApp = async ({ + uid, + name, + title, + indexUrl, + ownerUserId = null, +}: { + uid: string; + name: string; + title: string; + indexUrl: string; + ownerUserId?: number | null; +}) => { + await db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `description`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?, ?)', + [uid, name, title, `desc-${name}`, indexUrl, ownerUserId], + ); +}; + const tamperTokenSignature = (token: string): string => { const parts = token.split('.'); if ( parts.length !== 3 ) return `${token}x`; @@ -268,44 +294,36 @@ describe('AuthService private asset token helpers', () => { it('resolves bootstrap identity from app-under-user token without app lookup', async () => { const authService = createAuthService(); - const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0'; - const sessionUuid = 'f9000804-2fd3-4da5-819b-afc5296f90f7'; - const token = jwt.sign({ + const user = await insertUser(); + const session = await authService.sessionService.create_session(user, {}); + const token = authService.tokenService.sign('auth', { type: 'app-under-user', version: '0.0.0', - user_uid: userUid, + user_uid: user.uuid, app_uid: 'app-7e2d3016-8d36-456a-9dc7-b75b0f4f7683', - session: sessionUuid, - }, authService.global_config.jwt_secret, { expiresIn: 60 }); - - authService.get_session_ = vi.fn().mockResolvedValue({ - uuid: sessionUuid, - user_uid: userUid, - }); + session: authService.uuid_fpe.encrypt(session.uuid), + }, { expiresIn: 60 }); const identity = await authService.resolvePrivateBootstrapIdentityFromToken(token); expect(identity).toEqual({ - userUid, - sessionUuid, + userUid: user.uuid, + sessionUuid: session.uuid, }); - expect(authService.get_session_).toHaveBeenCalledWith(sessionUuid); }); it('rejects bootstrap identity when session owner does not match token user', async () => { const authService = createAuthService(); - const token = jwt.sign({ + const claimedUser = await insertUser(); + const actualSessionOwner = await insertUser(); + const actualSession = await authService.sessionService.create_session(actualSessionOwner, {}); + const token = authService.tokenService.sign('auth', { type: 'app-under-user', version: '0.0.0', - user_uid: '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0', + user_uid: claimedUser.uuid, app_uid: 'app-7e2d3016-8d36-456a-9dc7-b75b0f4f7683', - session: 'f9000804-2fd3-4da5-819b-afc5296f90f7', - }, authService.global_config.jwt_secret, { expiresIn: 60 }); - - authService.get_session_ = vi.fn().mockResolvedValue({ - uuid: 'f9000804-2fd3-4da5-819b-afc5296f90f7', - user_uid: '9885b80e-1a14-4c8d-9e3f-4fa5915b1136', - }); + session: authService.uuid_fpe.encrypt(actualSession.uuid), + }, { expiresIn: 60 }); await expect(authService.resolvePrivateBootstrapIdentityFromToken(token)) .rejects @@ -314,20 +332,15 @@ describe('AuthService private asset token helpers', () => { it('rejects bootstrap identity when expected app uid does not match token app uid', async () => { const authService = createAuthService(); - const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0'; - const sessionUuid = 'f9000804-2fd3-4da5-819b-afc5296f90f7'; - const token = jwt.sign({ + const user = await insertUser(); + const session = await authService.sessionService.create_session(user, {}); + const token = authService.tokenService.sign('auth', { type: 'app-under-user', version: '0.0.0', - user_uid: userUid, + user_uid: user.uuid, app_uid: 'app-7e2d3016-8d36-456a-9dc7-b75b0f4f7683', - session: sessionUuid, - }, authService.global_config.jwt_secret, { expiresIn: 60 }); - - authService.get_session_ = vi.fn().mockResolvedValue({ - uuid: sessionUuid, - user_uid: userUid, - }); + session: authService.uuid_fpe.encrypt(session.uuid), + }, { expiresIn: 60 }); await expect(authService.resolvePrivateBootstrapIdentityFromToken(token, { expectedAppUid: 'app-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', @@ -338,43 +351,38 @@ describe('AuthService private asset token helpers', () => { it('accepts bootstrap identity when expected app uid candidates include token app uid', async () => { const authService = createAuthService(); - const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0'; - const sessionUuid = 'f9000804-2fd3-4da5-819b-afc5296f90f7'; + const user = await insertUser(); + const session = await authService.sessionService.create_session(user, {}); const appUid = 'app-7e2d3016-8d36-456a-9dc7-b75b0f4f7683'; - const token = jwt.sign({ + const token = authService.tokenService.sign('auth', { type: 'app-under-user', version: '0.0.0', - user_uid: userUid, + user_uid: user.uuid, app_uid: appUid, - session: sessionUuid, - }, authService.global_config.jwt_secret, { expiresIn: 60 }); - - authService.get_session_ = vi.fn().mockResolvedValue({ - uuid: sessionUuid, - user_uid: userUid, - }); + session: authService.uuid_fpe.encrypt(session.uuid), + }, { expiresIn: 60 }); const identity = await authService.resolvePrivateBootstrapIdentityFromToken(token, { expectedAppUids: ['app-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', appUid], }); expect(identity).toEqual({ - userUid, - sessionUuid, + userUid: user.uuid, + sessionUuid: session.uuid, }); }); it('rejects bootstrap identity token when signature is tampered', async () => { const authService = createAuthService(); - const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0'; - const sessionUuid = 'f9000804-2fd3-4da5-819b-afc5296f90f7'; - const token = jwt.sign({ + const user = await insertUser(); + const session = await authService.sessionService.create_session(user, {}); + const token = authService.tokenService.sign('auth', { type: 'app-under-user', version: '0.0.0', - user_uid: userUid, + user_uid: user.uuid, app_uid: 'app-7e2d3016-8d36-456a-9dc7-b75b0f4f7683', - session: sessionUuid, - }, authService.global_config.jwt_secret, { expiresIn: 60 }); + session: authService.uuid_fpe.encrypt(session.uuid), + }, { expiresIn: 60 }); const tampered = tamperTokenSignature(token); await expect(authService.resolvePrivateBootstrapIdentityFromToken(tampered)) @@ -384,66 +392,47 @@ describe('AuthService private asset token helpers', () => { it('prefers oldest owner-matched app for hosted subdomain origins', async () => { const authService = createAuthService(); - const readSites = vi.fn().mockResolvedValue([{ user_id: 42 }]); - const readApps = vi.fn().mockResolvedValue([{ uid: 'app-oldest-owner-match' }]); - - authService.services = { - get: (name: string) => { - if ( name === 'database' ) { - return { - get: (_mode: unknown, dbName: string) => ( - dbName === 'sites' - ? { read: readSites } - : { read: readApps } - ), - }; - } - return { - emit: async () => { - }, - }; - }, - }; - authService.readCanonicalAppUidFromRedisCache = vi.fn().mockResolvedValue(undefined); - authService.writeCanonicalAppUidToRedisCache = vi.fn().mockResolvedValue(undefined); + const owner = await insertUser(); + const otherOwner = await insertUser(); + const subdomain = `beans${Math.random().toString(36).slice(2, 9)}`; + + await db.write( + 'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`) VALUES (?, ?, ?)', + [randomUUID(), subdomain, owner.id], + ); - const appUid = await authService.app_uid_from_origin('https://beans.puter.dev'); + await insertApp({ + uid: 'app-oldest-owner-match', + name: `oldest-owner-${subdomain}`, + title: `oldest-owner-${subdomain}`, + indexUrl: `https://${subdomain}.puter.dev/`, + ownerUserId: owner.id, + }); + await insertApp({ + uid: 'app-newer-owner-match', + name: `newer-owner-${subdomain}`, + title: `newer-owner-${subdomain}`, + indexUrl: `https://${subdomain}.puter.dev/index.html`, + ownerUserId: owner.id, + }); + await insertApp({ + uid: `app-other-owner-${randomUUID()}`, + name: `other-owner-${subdomain}`, + title: `other-owner-${subdomain}`, + indexUrl: `https://${subdomain}.puter.dev/`, + ownerUserId: otherOwner.id, + }); + + const appUid = await authService.app_uid_from_origin(`https://${subdomain}.puter.dev`); expect(appUid).toBe('app-oldest-owner-match'); - expect(readSites).toHaveBeenCalledWith( - 'SELECT user_id FROM subdomains WHERE subdomain = ? LIMIT 1', - ['beans'], - ); - expect(readApps).toHaveBeenCalled(); }); it('falls back to deterministic origin uid when hosted subdomain owner cannot be resolved', async () => { const authService = createAuthService(); - const readSites = vi.fn().mockResolvedValue([]); - const readApps = vi.fn().mockResolvedValue([]); - - authService.services = { - get: (name: string) => { - if ( name === 'database' ) { - return { - get: (_mode: unknown, dbName: string) => ( - dbName === 'sites' - ? { read: readSites } - : { read: readApps } - ), - }; - } - return { - emit: async () => { - }, - }; - }, - }; - authService.readCanonicalAppUidFromRedisCache = vi.fn().mockResolvedValue(undefined); - authService.writeCanonicalAppUidToRedisCache = vi.fn().mockResolvedValue(undefined); - - const uidFromPrivateAlias = await authService.app_uid_from_origin('https://beans.puter.dev'); - const uidFromStaticAlias = await authService.app_uid_from_origin('https://beans.puter.site'); + const subdomain = `beans${Math.random().toString(36).slice(2, 9)}`; + const uidFromPrivateAlias = await authService.app_uid_from_origin(`https://${subdomain}.puter.dev`); + const uidFromStaticAlias = await authService.app_uid_from_origin(`https://${subdomain}.puter.site`); expect(uidFromPrivateAlias).toBe(uidFromStaticAlias); expect(uidFromPrivateAlias.startsWith('app-')).toBe(true); @@ -451,31 +440,24 @@ describe('AuthService private asset token helpers', () => { it('prefers oldest app for non-hosted origins', async () => { const authService = createAuthService(); - const readApps = vi.fn().mockResolvedValue([{ uid: 'app-oldest-external' }]); - - authService.services = { - get: (name: string) => { - if ( name === 'database' ) { - return { - get: (_mode: unknown, dbName: string) => ( - dbName === 'apps' - ? { read: readApps } - : { read: vi.fn().mockResolvedValue([]) } - ), - }; - } - return { - emit: async () => { - }, - }; - }, - }; - authService.readCanonicalAppUidFromRedisCache = vi.fn().mockResolvedValue(undefined); - authService.writeCanonicalAppUidToRedisCache = vi.fn().mockResolvedValue(undefined); + const host = `${Math.random().toString(36).slice(2, 10)}.example.com`; + const origin = `https://${host}`; + + await insertApp({ + uid: 'app-oldest-external', + name: `oldest-external-${host}`, + title: `oldest-external-${host}`, + indexUrl: `${origin}/`, + }); + await insertApp({ + uid: 'app-newer-external', + name: `newer-external-${host}`, + title: `newer-external-${host}`, + indexUrl: `${origin}/index.html`, + }); - const appUid = await authService.app_uid_from_origin('https://example.com'); + const appUid = await authService.app_uid_from_origin(origin); expect(appUid).toBe('app-oldest-external'); - expect(readApps).toHaveBeenCalled(); }); it('collects canonical cache origins from app change payloads', () => { diff --git a/src/backend/src/services/database/BaseDatabaseAccessService.js b/src/backend/src/services/database/BaseDatabaseAccessService.js index 4e46eec949..65a5b897f3 100644 --- a/src/backend/src/services/database/BaseDatabaseAccessService.js +++ b/src/backend/src/services/database/BaseDatabaseAccessService.js @@ -16,10 +16,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -const { trace } = require('@opentelemetry/api'); -const BaseService = require('../BaseService'); -const { DB_WRITE, DB_READ } = require('./consts'); -const { spanify } = require('../../util/otelutil'); +import { BaseService } from '../BaseService.js'; +import { DB_WRITE, DB_READ } from './consts.js'; +import { spanify } from '../../util/otelutil.js'; /** * BaseDatabaseAccessService class extends BaseService to provide @@ -27,15 +26,10 @@ const { spanify } = require('../../util/otelutil'); * like reading, writing, and inserting data while managing * different database configurations and optimizations. */ -class BaseDatabaseAccessService extends BaseService { +export class BaseDatabaseAccessService extends BaseService { static DB_WRITE = DB_WRITE; static DB_READ = DB_READ; - _setDbSpanAttributes (query) { - const activeSpan = trace.getActiveSpan(); - if ( ! activeSpan ) return; - activeSpan.setAttribute('query', query); - activeSpan.setAttribute('trace', (new Error()).stack); - } + case ( choices ) { const engine_name = this.constructor.ENGINE_NAME; if ( Object.prototype.hasOwnProperty.call(choices, engine_name) ) { @@ -44,11 +38,6 @@ class BaseDatabaseAccessService extends BaseService { return choices.otherwise; } - // Call get() with an access mode and a scope. - // Right now it just returns `this`, but in the - // future it can be used to audit the behaviour - // of other services or handle service-specific - // database optimizations. /** * Retrieves the current instance of the service. * This method currently returns `this`, but it is designed @@ -58,13 +47,11 @@ class BaseDatabaseAccessService extends BaseService { * * @returns {BaseDatabaseAccessService} The current instance of the service. */ - get (_accessLevel, _scope) { + get () { return this; } read = spanify('database:read', async (query, params) => { - this._setDbSpanAttributes(query); - if ( this.config.slow ) await new Promise(rslv => setTimeout(rslv, 70)); return await this._read(query, params); }); @@ -78,7 +65,6 @@ class BaseDatabaseAccessService extends BaseService { * @returns {Promise<*>} */ async tryHardRead (query, params) { - if ( this.config.slow ) await new Promise(rslv => setTimeout(rslv, 70)); return this._tryHardRead(query, params); } @@ -93,7 +79,6 @@ class BaseDatabaseAccessService extends BaseService { * @returns {Promise<*>} */ async requireRead (query, params) { - if ( this.config.slow ) await new Promise(rslv => setTimeout(rslv, 70)); const results = this._tryHardRead(query, params); if ( results.length === 0 ) { throw new Error(`required read failed: ${ query}`); @@ -102,14 +87,10 @@ class BaseDatabaseAccessService extends BaseService { } pread = spanify('database:pread', async (query, params) => { - this._setDbSpanAttributes(query); - if ( this.config.slow ) await new Promise(rslv => setTimeout(rslv, 70)); return await this._read(query, params, { use_primary: true }); }); write = spanify('database:write', async (query, params) => { - this._setDbSpanAttributes(query); - if ( this.config.slow ) await new Promise(rslv => setTimeout(rslv, 70)); return await this._write(query, params); }); @@ -129,8 +110,4 @@ class BaseDatabaseAccessService extends BaseService { batch_write (statements) { return this._batch_write(statements); } -} - -module.exports = { - BaseDatabaseAccessService, -}; +} \ No newline at end of file diff --git a/src/gui/src/helpers/launch_app.js b/src/gui/src/helpers/launch_app.js index 145a2476fa..44718c469c 100644 --- a/src/gui/src/helpers/launch_app.js +++ b/src/gui/src/helpers/launch_app.js @@ -58,6 +58,56 @@ const endLaunchTransaction = (transaction) => { } }; +const fetchUserAppTokenForLaunch = async ({ appUid } = {}) => { + if ( ! appUid ) { + return { + token: null, + reason: 'missing-app-uid', + }; + } + + try { + const response = await fetch(`${window.api_origin }/auth/get-user-app-token`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${ window.auth_token}`, + }, + body: JSON.stringify({ app_uid: appUid }), + method: 'POST', + }); + + let responseBody; + try { + responseBody = await response.json(); + } catch ( error ) { + responseBody = null; + } + + if ( response.ok && responseBody?.token ) { + return { + token: responseBody.token, + }; + } + + return { + token: null, + reason: 'token-request-failed', + status: response.status, + responseCode: responseBody?.code ?? responseBody?.error?.code, + responseMessage: responseBody?.message ?? responseBody?.error?.message, + responseBody, + attempts: 1, + }; + } catch ( error ) { + return { + token: null, + reason: 'network-error', + error, + attempts: 1, + }; + } +}; + /** * Launches an app. * @@ -390,19 +440,42 @@ const launch_app = async (options) => { else if ( options.token ) { iframe_url.searchParams.append('puter.auth.token', options.token); } else { - // Try to acquire app token from the server - - let response = await fetch(`${window.api_origin }/auth/get-user-app-token`, { - 'headers': { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${ window.auth_token}`, - }, - 'body': JSON.stringify({ app_uid: app_info.uid ?? app_info.uuid }), - 'method': 'POST', + const tokenResult = await fetchUserAppTokenForLaunch({ + appUid: app_info.uid ?? app_info.uuid, }); - let res = await response.json(); - if ( res.token ) { - iframe_url.searchParams.append('puter.auth.token', res.token); + + if ( tokenResult?.token ) { + iframe_url.searchParams.append('puter.auth.token', tokenResult.token); + } else { + console.error('App launch blocked because app token could not be acquired', { + appName: app_info?.name ?? options?.name, + appUid: app_info?.uid ?? app_info?.uuid, + tokenResult, + }); + + const tokenErrorAppTitle = app_info?.title ?? app_info?.name ?? options?.name ?? 'this app'; + const safeTokenErrorAppTitle = window.html_encode + ? window.html_encode(tokenErrorAppTitle) + : tokenErrorAppTitle; + if ( typeof window.UIAlert === 'function' ) { + await window.UIAlert(`Couldn't open ${safeTokenErrorAppTitle}. Please try again.`); + } else { + window.alert(`Couldn't open ${tokenErrorAppTitle}. Please try again.`); + } + + const tokenFailureLaunchResult = { + launched: false, + requestedAppName, + openedAppName: null, + appInstanceID: null, + appUid: app_info?.uid ?? app_info?.uuid ?? null, + redirectedToFallback: privateLaunchRedirectDepth > 0, + deniedPrivateAccess: false, + privateAccess: privateAccessDecision ?? undefined, + authTokenAcquired: false, + }; + endLaunchTransaction(transaction); + return { launchResult: tokenFailureLaunchResult }; } }