Added theme on backend (#2161)

* Added theme on backend
This commit is contained in:
Kasra Bigdeli
2024-10-14 23:58:34 -07:00
committed by GitHub
parent 155281d654
commit ab19b07d5c
9 changed files with 471 additions and 0 deletions
+2
View File
@@ -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)
+52
View File
@@ -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<CapRoverTheme[]> {
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<string | undefined> {
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 () {
+11
View File
@@ -0,0 +1,11 @@
export interface CapRoverExtraTheme {
siderTheme?: string
}
export default interface CapRoverTheme {
content: string
name: string
extra?: string
headEmbed?: string
builtIn?: boolean
}
+27
View File
@@ -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
+2
View File
@@ -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()
+91
View File
@@ -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
+258
View File
@@ -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<CapRoverTheme | undefined> {
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()
})
}
}
+4
View File
@@ -4,6 +4,10 @@ import * as yaml from 'yaml'
import Logger from './Logger'
export default class Utils {
static copyObject<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj)) as T
}
static removeHttpHttps(input: string) {
input = input.trim()
input = input.replace(/^(?:http?:\/\/)?/i, '')
+24
View File
@@ -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:
<link href="https://fonts.googleapis.com/css?family=Quicksand:300,500" rel="stylesheet" />
###CapRoverTheme.extra:
{siderTheme:'dark'}