From ab19b07d5c579de2fe92b95fa53fe17923132973 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Mon, 14 Oct 2024 23:58:34 -0700 Subject: [PATCH] Added theme on backend (#2161) * Added theme on backend --- src/app.ts | 2 + src/datastore/DataStore.ts | 52 +++++ src/models/CapRoverTheme.ts | 11 ++ src/routes/public/ThemePublicRouter.ts | 27 +++ src/routes/user/system/SystemRouter.ts | 2 + src/routes/user/system/ThemesRouter.ts | 91 +++++++++ src/user/ThemeManager.ts | 258 +++++++++++++++++++++++++ src/utils/Utils.ts | 4 + template/themes/10-legacy | 24 +++ 9 files changed, 471 insertions(+) create mode 100644 src/models/CapRoverTheme.ts create mode 100644 src/routes/public/ThemePublicRouter.ts create mode 100644 src/routes/user/system/ThemesRouter.ts create mode 100644 src/user/ThemeManager.ts create mode 100644 template/themes/10-legacy diff --git a/src/app.ts b/src/app.ts index f1e6469..8e10722 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,7 @@ import InjectionExtractor from './injection/InjectionExtractor' import * as Injector from './injection/Injector' import DownloadRouter from './routes/download/DownloadRouter' import LoginRouter from './routes/login/LoginRouter' +import ThemePublicRouter from './routes/public/ThemePublicRouter' import UserRouter from './routes/user/UserRouter' import CaptainManager from './user/system/CaptainManager' import CaptainConstants from './utils/CaptainConstants' @@ -215,6 +216,7 @@ app.use( API_PREFIX + CaptainConstants.apiVersion + '/downloads/', DownloadRouter ) +app.use(API_PREFIX + CaptainConstants.apiVersion + '/theme/', ThemePublicRouter) // secured end points app.use(API_PREFIX + CaptainConstants.apiVersion + '/user/', UserRouter) diff --git a/src/datastore/DataStore.ts b/src/datastore/DataStore.ts index a128e8d..b9751fc 100644 --- a/src/datastore/DataStore.ts +++ b/src/datastore/DataStore.ts @@ -7,8 +7,10 @@ import { AutomatedCleanupConfigsCleaner, IAutomatedCleanupConfigs, } from '../models/AutomatedCleanupConfigs' +import CapRoverTheme from '../models/CapRoverTheme' import CaptainConstants from '../utils/CaptainConstants' import CaptainEncryptor from '../utils/Encryptor' +import Utils from '../utils/Utils' import AppsDataStore from './AppsDataStore' import ProDataStore from './ProDataStore' import ProjectsDataStore from './ProjectsDataStore' @@ -28,6 +30,8 @@ const NGINX_CAPTAIN_CONFIG = 'nginxCaptainConfig' const CUSTOM_ONE_CLICK_APP_URLS = 'oneClickAppUrls' const FEATURE_FLAGS = 'featureFlags' const AUTOMATED_CLEANUP = 'automatedCleanup' +const THEMES = 'themes' +const CURRENT_THEME = 'currentTheme' const DEFAULT_CAPTAIN_ROOT_DOMAIN = 'captain.localhost' @@ -104,6 +108,54 @@ class DataStore { }) } + getThemes(): Promise { + const self = this + return Promise.resolve().then(function () { + return self.data.get(THEMES) || [] + }) + } + + deleteTheme(themeName: string) { + const self = this + return Promise.resolve() + .then(function () { + return self.getThemes() + }) + .then(function (themesFetched) { + self.data.set( + THEMES, + Utils.copyObject(themesFetched).filter( + (it) => it.name !== themeName + ) + ) + }) + } + + saveThemes(themes: CapRoverTheme[]) { + const self = this + return Promise.resolve().then(function () { + self.data.set( + THEMES, + (themes || []).filter((it) => !it.builtIn) + ) + }) + } + + setCurrentTheme(themeName: string | undefined) { + const self = this + return Promise.resolve() // + .then(function () { + return self.data.set(CURRENT_THEME, themeName || '') + }) + } + + getCurrentThemeName(): Promise { + const self = this + return Promise.resolve().then(function () { + return self.data.get(CURRENT_THEME) + }) + } + setHashedPassword(newHashedPassword: string) { const self = this return Promise.resolve().then(function () { diff --git a/src/models/CapRoverTheme.ts b/src/models/CapRoverTheme.ts new file mode 100644 index 0000000..b8e24fa --- /dev/null +++ b/src/models/CapRoverTheme.ts @@ -0,0 +1,11 @@ +export interface CapRoverExtraTheme { + siderTheme?: string +} + +export default interface CapRoverTheme { + content: string + name: string + extra?: string + headEmbed?: string + builtIn?: boolean +} diff --git a/src/routes/public/ThemePublicRouter.ts b/src/routes/public/ThemePublicRouter.ts new file mode 100644 index 0000000..36b302a --- /dev/null +++ b/src/routes/public/ThemePublicRouter.ts @@ -0,0 +1,27 @@ +import express = require('express') +import ApiStatusCodes from '../../api/ApiStatusCodes' +import BaseApi from '../../api/BaseApi' +import { ThemeManagerPublic } from '../../user/ThemeManager' + +const router = express.Router() + +router.get('/current', function (req, res, next) { + return Promise.resolve() + .then(function () { + return new ThemeManagerPublic().getCurrentTheme() + }) + .then(function (t) { + const baseApi = new BaseApi( + ApiStatusCodes.STATUS_OK, + 'Current theme is retrieved.' + ) + baseApi.data = { + theme: t, + } + + res.send(baseApi) + }) + .catch(ApiStatusCodes.createCatcher(res)) +}) + +export default router diff --git a/src/routes/user/system/SystemRouter.ts b/src/routes/user/system/SystemRouter.ts index d405890..dc552e8 100644 --- a/src/routes/user/system/SystemRouter.ts +++ b/src/routes/user/system/SystemRouter.ts @@ -12,10 +12,12 @@ import CaptainConstants from '../../../utils/CaptainConstants' import Logger from '../../../utils/Logger' import Utils from '../../../utils/Utils' import SystemRouteSelfHostRegistry from './selfhostregistry/SystemRouteSelfHostRegistry' +import ThemesRouter from './ThemesRouter' const router = express.Router() router.use('/selfhostregistry/', SystemRouteSelfHostRegistry) +router.use('/themes/', ThemesRouter) router.post('/createbackup/', function (req, res, next) { const backupManager = CaptainManager.get().getBackupManager() diff --git a/src/routes/user/system/ThemesRouter.ts b/src/routes/user/system/ThemesRouter.ts new file mode 100644 index 0000000..ed4779f --- /dev/null +++ b/src/routes/user/system/ThemesRouter.ts @@ -0,0 +1,91 @@ +import express = require('express') +import ApiStatusCodes from '../../../api/ApiStatusCodes' +import BaseApi from '../../../api/BaseApi' +import InjectionExtractor from '../../../injection/InjectionExtractor' +import CapRoverTheme from '../../../models/CapRoverTheme' +import { ThemeManager } from '../../../user/ThemeManager' +import Logger from '../../../utils/Logger' + +const router = express.Router() + +router.post('/setcurrent/', function (req, res, next) { + const dataStore = + InjectionExtractor.extractUserFromInjected(res).user.dataStore + + const themeName = req.body.themeName || '' + + return Promise.resolve() + .then(function () { + return new ThemeManager(dataStore).setCurrent(themeName) + }) + .then(function () { + const msg = 'Current theme is stored.' + Logger.d(msg) + res.send(new BaseApi(ApiStatusCodes.STATUS_OK, msg)) + }) + .catch(ApiStatusCodes.createCatcher(res)) +}) + +router.post('/update/', function (req, res, next) { + const dataStore = + InjectionExtractor.extractUserFromInjected(res).user.dataStore + const oldName = req.body.oldName || '' + const theme: CapRoverTheme = { + name: req.body.name || '', + content: req.body.content || '', + extra: req.body.extra || '', + headEmbed: req.body.headEmbed || '', + } + + return Promise.resolve() + .then(function () { + return new ThemeManager(dataStore).updateTheme(oldName, theme) + }) + .then(function () { + const msg = 'Theme is stored.' + Logger.d(msg) + res.send(new BaseApi(ApiStatusCodes.STATUS_OK, msg)) + }) + .catch(ApiStatusCodes.createCatcher(res)) +}) + +router.post('/delete/', function (req, res, next) { + const dataStore = + InjectionExtractor.extractUserFromInjected(res).user.dataStore + const themeName = req.body.themeName || '' + + return Promise.resolve() + .then(function () { + return new ThemeManager(dataStore).deleteTheme(themeName) + }) + .then(function () { + const msg = 'Theme is deleted.' + Logger.d(msg) + res.send(new BaseApi(ApiStatusCodes.STATUS_OK, msg)) + }) + .catch(ApiStatusCodes.createCatcher(res)) +}) + +router.get('/all/', function (req, res, next) { + const dataStore = + InjectionExtractor.extractUserFromInjected(res).user.dataStore + + return Promise.resolve() + .then(function () { + return new ThemeManager(dataStore).getAllThemes() + }) + .then(function (themes) { + const baseApi = new BaseApi( + ApiStatusCodes.STATUS_OK, + 'Themes are retrieved.' + ) + baseApi.data = { + themes: themes, + } + + res.send(baseApi) + }) + .catch(ApiStatusCodes.createCatcher(res)) +}) + +export default router diff --git a/src/user/ThemeManager.ts b/src/user/ThemeManager.ts new file mode 100644 index 0000000..9867782 --- /dev/null +++ b/src/user/ThemeManager.ts @@ -0,0 +1,258 @@ +import ApiStatusCodes from '../api/ApiStatusCodes' +import DataStore from '../datastore/DataStore' +import DataStoreProvider from '../datastore/DataStoreProvider' +import CapRoverTheme from '../models/CapRoverTheme' +import CaptainConstants from '../utils/CaptainConstants' +import Logger from '../utils/Logger' +import Utils from '../utils/Utils' +import fs = require('fs-extra') + +const builtInThemes = [] as CapRoverTheme[] + +/** + * Parses a string containing themed configuration fields into a JSON object. + * Each field must start with "###CapRoverTheme." followed by the field name and its content. + * The function dynamically identifies and extracts these fields, preserving their original formatting. + * Field names are converted to lowercase to serve as keys in the resulting JSON object, + * with the corresponding content as the values, maintaining any internal formatting. + * + * @param {string} input - Themed configuration string. + * @return {Object} JSON object with keys representing field names and values containing the respective content. + * + * Example: + * Input: + * "###CapRoverTheme.name: + * Green Arrow + * ###CapRoverTheme.content: + * { colorA: '#fff', + * colorB: '#fff' + * }" + * Output: + * { name: "Green Arrow", content: "{ colorA: '#fff' , colorB: '#fff' }" } + */ + +function parseCapRoverTheme(input: string) { + const result = {} as { [id: string]: string } + const lines = input.split('\n') + let currentField = undefined as string | undefined + + lines.forEach((line, index) => { + if (line.startsWith('###CapRoverTheme.')) { + // Calculate the start position for the field name and remove the prefix '###CapRoverTheme.' + const start = line.indexOf('.') + 1 + currentField = line.substring(start, line.length - 1).trim() + result[currentField] = '' + } else if (currentField) { + // Check if we already have content for the current field to add a newline + if (result[currentField].length > 0) { + result[currentField] += '\n' + line + } else { + result[currentField] += line + } + } + }) + + for (const key in result) { + result[key] = result[key].trim() + } + + return result +} + +function populateBuiltInThemes() { + const themesDirectory = __dirname + '/../../template/themes' + const rawContent = [] as string[] + + const files = fs.readdirSync(themesDirectory).map((it) => { + return { + fileName: it, + number: parseInt(it.split('-')[0]), + } + }) + + files.sort((a, b) => a.number - b.number) + + files + .map((it) => it.fileName) + .forEach((file) => { + const fileContent = fs.readFileSync( + `${themesDirectory}/${file}`, + 'utf8' + ) + rawContent.push(fileContent) + }) + + rawContent.forEach((it) => { + const parsedTheme = { + ...parseCapRoverTheme(it), + builtIn: true, + } as CapRoverTheme + builtInThemes.push(parsedTheme) + }) +} + +populateBuiltInThemes() + +export class ThemeManager { + constructor(private dataStore: DataStore) {} + + getAllThemes() { + const self = this + + return Promise.resolve() // + .then(function () { + return self.dataStore.getThemes() + }) + .then(function (themes) { + return [...builtInThemes, ...themes] + }) + } + + deleteTheme(themeName: string) { + const self = this + return Promise.resolve() // + .then(function () { + return Promise.all([ + self.getAllThemes(), + self.dataStore.getCurrentThemeName(), + ]) + }) + .then(function ([themesFetched, currentTheme]) { + const themes = Utils.copyObject(themesFetched) + const newThemes = [] as CapRoverTheme[] + themes.forEach((it) => { + if (it.name !== themeName) { + newThemes.push(it) + } else if (it.builtIn) { + throw ApiStatusCodes.createError( + ApiStatusCodes.ILLEGAL_PARAMETER, + 'Cannot delete a built-in theme' + ) + } + }) + + if (themes.length === newThemes.length) { + throw ApiStatusCodes.createError( + ApiStatusCodes.ILLEGAL_PARAMETER, + 'Theme not found' + ) + } + + return Promise.resolve() + .then(function () { + if (currentTheme && currentTheme === themeName) { + return self.dataStore.setCurrentTheme('') + } + }) + .then(function () { + return self.dataStore.deleteTheme(themeName) + }) + }) + } + + updateTheme(oldName: string, theme: CapRoverTheme) { + const self = this + return Promise.resolve() + .then(function () { + theme.builtIn = false + return self.getAllThemes() + }) + .then(function (themesFetched) { + const themes = Utils.copyObject(themesFetched) + const idx = themes.findIndex((t) => t.name === oldName) + if (!oldName) { + // new theme + + if (themes.some((t) => t.name === theme.name)) { + throw ApiStatusCodes.createError( + ApiStatusCodes.ILLEGAL_PARAMETER, + 'Wanted to store a new theme, but it already exists with the same name' + ) + } + + themes.push(theme) + } else if (idx >= 0) { + // replacing existing theme + + if (themes[idx].builtIn) { + throw ApiStatusCodes.createError( + ApiStatusCodes.ILLEGAL_PARAMETER, + 'Cannot edit a built-in theme' + ) + } + + themes[idx] = theme + } else { + throw ApiStatusCodes.createError( + ApiStatusCodes.ILLEGAL_PARAMETER, + 'Theme not found' + ) + } + + return self.dataStore + .saveThemes(themes) // + .then(() => { + return self.dataStore.setCurrentTheme(theme.name) + }) + }) + } + + setCurrent(themeName: string) { + const self = this + + return Promise.resolve() + .then(function () { + return self.getAllThemes() + }) + .then(function (themes) { + if (!themeName || themes.some((it) => it.name === themeName)) + return self.dataStore.setCurrentTheme(themeName) + + throw ApiStatusCodes.createError( + ApiStatusCodes.ILLEGAL_PARAMETER, + 'Theme not found' + ) + }) + } + + getCurrentTheme(): Promise { + const self = this + + return Promise.resolve() + .then(function () { + return Promise.all([ + self.getAllThemes(), + self.dataStore.getCurrentThemeName(), + ]) + }) + .then(function ([themes, themeName]) { + if (!themeName) return undefined + + const theme = themes.find((it) => it.name === themeName) + + if (!theme) { + Logger.e( + new Error( + 'Theme name was provided but could not be found: ' + + themeName + ) + ) + } + + return theme + }) + } +} + +export class ThemeManagerPublic { + private static themeManagerPublic = new ThemeManager( + DataStoreProvider.getDataStore(CaptainConstants.rootNameSpace) + ) + + getCurrentTheme() { + return Promise.resolve() // + .then(function () { + return ThemeManagerPublic.themeManagerPublic.getCurrentTheme() + }) + } +} diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 995f1ae..55c1255 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -4,6 +4,10 @@ import * as yaml from 'yaml' import Logger from './Logger' export default class Utils { + static copyObject(obj: T): T { + return JSON.parse(JSON.stringify(obj)) as T + } + static removeHttpHttps(input: string) { input = input.trim() input = input.replace(/^(?:http?:\/\/)?/i, '') diff --git a/template/themes/10-legacy b/template/themes/10-legacy new file mode 100644 index 0000000..2098b83 --- /dev/null +++ b/template/themes/10-legacy @@ -0,0 +1,24 @@ +###CapRoverTheme.name: +Legacy + + +###CapRoverTheme.content: +{ +algorithm: isDarkMode ? darkAlgorithm : defaultAlgorithm, +token: { +colorPrimary: '#1b8ad3', +colorLink: '#1b8ad3', +fontFamily: `QuickSand, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, +'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', +'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'`, +} +} + + + +###CapRoverTheme.headEmbed: + + + +###CapRoverTheme.extra: +{siderTheme:'dark'} \ No newline at end of file