Release 1.13 (#2166)

* Fixed https://github.com/caprover/caprover/issues/2112

* Updated changelog

* Force rebuild to capture the frontend change

* Updated changelog

* added fallback ip for installation if public ip not found

* Added project definition to the config

* Added project router

* Updated apps router

* Fixed update path

* Added parent ID for projects

* Typo fix

* Not allow parent to be deleted if there are sub projects

* Fixed typo in string template

* Allow register project to accept description

* Added functionality to remove multiple projects

* Fixed the project deletion

* Fixed the project deletion

* Close https://github.com/caprover/caprover/issues/2153

* Added changelog

* updated changelog

* Updated changelog

* Better error

* Force build

* add MariaDB to README.md

* Improved MacOs startup

* Added theme on backend (#2161)

* Added theme on backend

* Added themes

* Added another theme

* Updated changelog

* Reformat

* Improved dev code

* Improved dev code

* Added another theme

* Updated packages (#2165)

* Updated packages

* Updated packages

* Fixed formatting

* Fixed tests

* Returning project upon creation

* Preparing release

* updated frontend

* Added some test to fix node 20

* Revert "Added some test to fix node 20"

This reverts commit 18a59794b8.

* Updated changelog

---------

Co-authored-by: Robert Silén <robert.silen@mariadb.org>
This commit is contained in:
Kasra Bigdeli
2024-10-19 10:24:36 -07:00
committed by GitHub
parent 29a4921165
commit 8a5d3f9738
68 changed files with 5226 additions and 2778 deletions
-6
View File
@@ -1,6 +0,0 @@
# don't ever lint node_modules
node_modules
# don't lint build output (make sure it's set to your correct build folder name)
built
# don't lint nyc coverage output
coverage
-23
View File
@@ -1,23 +0,0 @@
// eslint-disable-next-line no-undef
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
"no-case-declarations": "off",
"no-useless-escape": "off",
}
};
+9 -1
View File
@@ -9,12 +9,20 @@
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.fixAll.eslint": "explicit"
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "explicit"
},
"typescript.referencesCodeLens.enabled": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"cSpell.words": ["csrf", "definitelytyped", "dockerode", "promisified"],
"[shellscript]": {
"editor.defaultFormatter": "foxundermoon.shell-format"
},
"[plaintext]": {
"editor.formatOnSave": false,
"editor.defaultFormatter": null
},
"files.associations": {
"*.theme": "plaintext"
}
}
+12
View File
@@ -2,6 +2,18 @@
- N/A
## [1.13.0] - 2024-10-19
- New: Added project structure [issue-158](https://github.com/caprover/caprover-frontend/pull/158)
- New: Added translations in multiple languages [issue-159](https://github.com/caprover/caprover-frontend/pull/159)
- New: Added themes! Now you can pick your favorite theme or build a new one! [issue-160](https://github.com/caprover/caprover-frontend/pull/160)
- New: Added automatic IP fallback on installation [ca196e5](https://github.com/caprover/caprover/commit/ca196e51be2df80836ff027a99bb92dde83c4f7f)
- New: Disallow passphrase protected SSH keys [issue-2153](https://github.com/caprover/caprover/issues/2153)
- Fixed: Deploy time now uses the proper locale [issue-157](https://github.com/caprover/caprover-frontend/issues/157)
- Fixed: The app log box is resizable again [issue-2112](https://github.com/caprover/caprover/issues/2112)
- Fixed: Showing the missing timezones due to daylight saving time [issue-2110](https://github.com/caprover/caprover/issues/2110)
- Fixed: Log Search filter crash on invalid Regex [issue-2128](https://github.com/caprover/caprover/issues/2128)
## [1.12.0] - 2024-08-17
**IMPORTANT**: this version bumps the minimum Docker API to 1.43. Please run `docker version | grep API` before upgrading your CapRover installation.
+2 -4
View File
@@ -14,8 +14,6 @@ Easiest app/database deployment platform and webserver package for your NodeJS,
No Docker, nginx knowledge required!
[![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?url=https%3A%2F%2Fgithub.com%2Fcaprover%2Fcaprover&via=cap_rover&text=I%20found%20the%20easiest%20webserver%20package%20for%20NodeJS%2C%20PHP%2C%20MySQL%2C%20WordPress%20and%20everything%21&hashtags=CapRover%2Cnodejs%2Cdocker%2Cnginx%2Cwebdev)
<a href="https://youtu.be/VPHEXPfsvyQ" target="_blank" title="YouTube">
<img src="https://raw.githubusercontent.com/caprover/caprover-website/master/graphics/screenshots-video-small.png" alt="YouTube"/>
</a>
@@ -23,7 +21,7 @@ No Docker, nginx knowledge required!
## What's this?
CapRover is an extremely easy to use app/database deployment & web server manager for your **NodeJS, Python, PHP, ASP.NET, Ruby, MySQL, MongoDB, Postgres, WordPress (and etc...)** applications!
CapRover is an extremely easy to use app/database deployment & web server manager for your **NodeJS, Python, PHP, ASP.NET, Ruby, MariaDB, MySQL, MongoDB, Postgres, WordPress (and etc...)** applications!
It's blazingly fast and very robust as it uses Docker, nginx, LetsEncrypt and NetData under the hood behind its simple-to-use interface.
@@ -44,7 +42,7 @@ It's blazingly fast and very robust as it uses Docker, nginx, LetsEncrypt and Ne
- A [web] developer who does not like spending hours and days setting up a server, build tools, sending code to server, build it, get an SSL certificate, install it, update nginx over and over again.
- A developer who uses expensive services like Heroku, Microsoft Azure and etc. And is interested in reducing their cost by 50x (Heroku charges 250USD/month for their 2gb instance, the same server is 5$ on Hetzner!!)
- Someone who prefers to write more of `showResults(getUserList())` and not much of `$ apt-get install libstdc++6 > /dev/null`
- A developer who likes installing MySQL, MongoDB and etc on their server by selecting from a dropdown and clicking on install!
- A developer who likes installing MariaDB, MySQL, MongoDB and etc on their server by selecting from a dropdown and clicking on install!
- How much server/docker/linux knowledge is required to set up a CapRover server? Answer: Knowledge of Copy & Paste!! Head over to "Getting Started" for information on what to copy & paste ;-)
## Learn More!
+5 -2
View File
@@ -48,9 +48,12 @@ echo "Building finished"
cd $ORIG_DIR
mv $FRONTEND_DIR/caprover-frontend/build ./dist-frontend
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
export DOCKER_CLI_EXPERIMENTAL=enabled
sudo apt-get update && sudo apt-get install qemu-user-static
# docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker run --rm --privileged tonistiigi/binfmt --install all
# export DOCKER_CLI_EXPERIMENTAL=enabled
docker buildx ls
docker buildx rm mybuilder || echo "mybuilder not found"
docker buildx create --name mybuilder
docker buildx use mybuilder
+6 -3
View File
@@ -44,7 +44,7 @@ echo $IMAGE_NAME:$CAPROVER_VERSION
echo "**************************************"
echo "**************************************"
FRONTEND_COMMIT_HASH=89516709d5462c38554cae5b62845432adf3f88a
FRONTEND_COMMIT_HASH=9f6c377137088c10b5582c4c9cd8285a9bd450d9
## Building frontend app
ORIG_DIR=$(pwd)
@@ -62,9 +62,12 @@ echo "Building finished"
cd $ORIG_DIR
mv $FRONTEND_DIR/caprover-frontend/build ./dist-frontend
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
export DOCKER_CLI_EXPERIMENTAL=enabled
sudo apt-get update && sudo apt-get install qemu-user-static
# docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker run --rm --privileged tonistiigi/binfmt --install all
# export DOCKER_CLI_EXPERIMENTAL=enabled
docker buildx ls
docker buildx rm mybuilder || echo "mybuilder not found"
docker buildx create --name mybuilder
docker buildx use mybuilder
@@ -1,10 +1,11 @@
#!/bin/sh
if ! [ $(id -u) <> 0 ]; then
if ! [ $(id -u) ] <>0; then
echo "Must not be run as sudo or root on macos (macos security) please run the step 1 as root and this step as standard user"
exit 1
fi
pwd >currentdirectory
docker service rm $(docker service ls -q)
sleep 1
docker secret rm captain-salt
+1
View File
@@ -4,6 +4,7 @@ RUN apk add --update --no-cache make gcc g++ git curl openssl openssh
WORKDIR /usr/src/app
COPY . ./
# Build backend code
RUN npm ci && \
npm run build && \
+18
View File
@@ -0,0 +1,18 @@
import pluginJs from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default [
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-require-imports': 'off',
'no-useless-escape': 'off',
},
},
]
+2
View File
@@ -1,5 +1,7 @@
/* eslint-disable no-undef */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
"^.+\\.tsx?$": "ts-jest",
"^.+\\.ts?$": "ts-jest",
+3775 -2616
View File
File diff suppressed because it is too large Load Diff
+37 -33
View File
@@ -4,80 +4,84 @@
"private": true,
"scripts": {
"disable-otp": "node ./built/scripts/disable-otp.js",
"dev": "npm run build && sudo ./dev-scripts/dev-reset-service.sh",
"dev": "sudo 'echo' && npm run build && sudo ./dev-scripts/dev-reset-service.sh",
"clean": "npm run build && sudo ./dev-scripts/dev-clean-run-as-dev.sh",
"lint": "eslint -c .eslintrc.js --ext .ts ./src",
"lint-fix": "eslint --fix -c .eslintrc.js --ext .ts ./src",
"lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"formatter": "prettier --check './src/**/*.ts'",
"formatter-write": "prettier --write './src/**/*.ts'",
"build": "echo 'RECOMPILING' && npx madge --circular --extensions ts ./ && rm -rf ./built && npx tsc && echo 'Build successful'",
"test": "jest"
},
"dependencies": {
"axios": "^0.21.1",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
"body-parser": "^1.20.3",
"configstore": "^5.0.1",
"cookie-parser": "~1.4.6",
"cookie-parser": "~1.4.7",
"cron": "^3.1.7",
"debug": "~4.3.6",
"dockerode": "^3.3.5",
"debug": "~4.3.7",
"dockerode": "^4.0.2",
"ejs": "^3.1.10",
"express": "^4.19.2",
"fs-extra": "^10.1.0",
"express": "^4.21.1",
"fs-extra": "^11.2.0",
"http-proxy": "^1.18.1",
"is-valid-path": "^0.1.1",
"js-base64": "^3.7.7",
"jsonwebtoken": "^8.5.1",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"morgan": "^1.10.0",
"multer": "^1.4.4",
"on-finished": "^2.4.1",
"prettier": "2.8.8",
"public-ip": "^4.0.4",
"prettier": "3.3.3",
"public-ip": "^7.0.1",
"recursive-readdir": "^2.2.3",
"request": "^2.88.2",
"require-from-string": "^2.0.2",
"serve-favicon": "~2.5.0",
"shell-quote": "^1.8.1",
"simple-git": "^2.48.0",
"ssh2": "^1.15.0",
"tar": "^6.2.1",
"typescript": "^4.9.5",
"uuid": "^8.3.2",
"simple-git": "^3.27.0",
"ssh2": "^1.16.0",
"tar": "^7.4.3",
"typescript": "^5.6.3",
"uuid": "^10.0.0",
"validator": "^13.12.0",
"yaml": "^1.10.2"
"yaml": "^2.6.0"
},
"devDependencies": {
"@eslint/js": "^9.12.0",
"@types/bcryptjs": "^2.4.6",
"@types/configstore": "^5.0.1",
"@types/cookie-parser": "^1.4.7",
"@types/debug": "^4.1.12",
"@types/dockerode": "^3.3.31",
"@types/ejs": "^3.1.5",
"@types/express": "^4.17.21",
"@types/fs-extra": "^9.0.13",
"@types/express": "^5.0.0",
"@types/fs-extra": "^11.0.4",
"@types/http-proxy": "^1.17.15",
"@types/is-valid-path": "^0.1.2",
"@types/jest": "^27.5.2",
"@types/jest": "^29.5.13",
"@types/js-base64": "^3.3.1",
"@types/jsonwebtoken": "^8.5.9",
"@types/jsonwebtoken": "^9.0.7",
"@types/moment": "^2.13.0",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.11",
"@types/multer": "^1.4.12",
"@types/on-finished": "^2.3.4",
"@types/request": "^2.48.12",
"@types/require-from-string": "^1.2.3",
"@types/serve-favicon": "^2.5.7",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "^0.5.52",
"@types/ssh2": "^1.15.1",
"@types/tar": "^6.1.13",
"@types/uuid": "^8.3.4",
"@types/validator": "^13.12.0",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"eslint": "^7.32.0",
"jest": "^27.5.1",
"madge": "^5.0.2",
"ts-jest": "^27.1.5"
"@types/uuid": "^10.0.0",
"@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^8.10.0",
"@typescript-eslint/parser": "^8.10.0",
"eslint": "^9.12.0",
"globals": "^15.11.0",
"jest": "^29.7.0",
"madge": "^8.0.0",
"ts-jest": "^29.2.5",
"typescript-eslint": "^8.10.0"
}
}
+4 -1
View File
@@ -1,7 +1,10 @@
class BaseApi {
public data: any
constructor(public status: number, public description: string) {
constructor(
public status: number,
public description: string
) {
this.data = {}
}
}
+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)
+25 -2
View File
@@ -1,5 +1,18 @@
import { v4 as uuid } from 'uuid'
import ApiStatusCodes from '../api/ApiStatusCodes'
import {
AppDeployTokenConfig,
IAllAppDefinitions,
IAppDef,
IAppDefSaved,
IAppEnvVar,
IAppPort,
IAppTag,
IAppVersion,
IAppVolume,
IHttpAuth,
RepoInfo,
} from '../models/AppDefinition'
import { IBuiltImage } from '../models/IBuiltImage'
import Authenticator from '../user/Authenticator'
import ApacheMd5 from '../utils/ApacheMd5'
@@ -31,7 +44,10 @@ function isPortValid(portNumber: number) {
class AppsDataStore {
private encryptor: CaptainEncryptor
constructor(private data: configstore, private namepace: string) {}
constructor(
private data: configstore,
private namepace: string
) {}
setEncryptor(encryptor: CaptainEncryptor) {
this.encryptor = encryptor
@@ -611,6 +627,7 @@ class AppsDataStore {
updateAppDefinitionInDb(
appName: string,
projectId: string | undefined,
description: string,
instanceCount: number,
captainDefinitionRelativeFilePath: string,
@@ -705,6 +722,7 @@ class AppsDataStore {
appObj.preDeployFunction = preDeployFunction
appObj.serviceUpdateOverride = serviceUpdateOverride
appObj.description = description
appObj.projectId = projectId
appObj.tags = tags
appObj.appDeployTokenConfig = {
@@ -860,7 +878,11 @@ class AppsDataStore {
* @param hasPersistentData whether the app has persistent data, you can only run one instance of the app.
* @returns {Promise}
*/
registerAppDefinition(appName: string, hasPersistentData: boolean) {
registerAppDefinition(
appName: string,
projectId: string | undefined,
hasPersistentData: boolean
) {
const self = this
return new Promise<IAppDef>(function (resolve, reject) {
@@ -886,6 +908,7 @@ class AppsDataStore {
const defaultAppDefinition: IAppDef = {
hasPersistentData: !!hasPersistentData,
projectId: projectId,
description: '',
instanceCount: 1,
captainDefinitionRelativeFilePath:
+65 -2
View File
@@ -7,11 +7,15 @@ 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'
import RegistriesDataStore from './RegistriesDataStore'
import { NetDataInfo } from '../models/NetDataInfo'
// keys:
const NAMESPACE = 'namespace'
@@ -27,6 +31,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'
@@ -58,6 +64,7 @@ class DataStore {
private appsDataStore: AppsDataStore
private registriesDataStore: RegistriesDataStore
proDataStore: ProDataStore
private projectsDataStore: ProjectsDataStore
constructor(namespace: string) {
const data = new Configstore(
@@ -72,6 +79,10 @@ class DataStore {
this.namespace = namespace
this.data.set(NAMESPACE, namespace)
this.appsDataStore = new AppsDataStore(this.data, namespace)
this.projectsDataStore = new ProjectsDataStore(
this.data,
this.appsDataStore
)
this.proDataStore = new ProDataStore(this.data)
this.registriesDataStore = new RegistriesDataStore(this.data, namespace)
}
@@ -98,6 +109,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 () {
@@ -117,7 +176,7 @@ class DataStore {
return Promise.resolve().then(function () {
return self.data.set(
AUTOMATED_CLEANUP,
AutomatedCleanupConfigsCleaner.cleanup(configs)
AutomatedCleanupConfigsCleaner.sanitizeInput(configs)
)
})
}
@@ -127,7 +186,7 @@ class DataStore {
return Promise.resolve().then(function () {
return (
self.data.get(AUTOMATED_CLEANUP) ||
AutomatedCleanupConfigsCleaner.cleanup({
AutomatedCleanupConfigsCleaner.sanitizeInput({
mostRecentLimit: 0,
cronSchedule: '',
timezone: '',
@@ -195,6 +254,10 @@ class DataStore {
return this.appsDataStore
}
getProjectsDataStore() {
return this.projectsDataStore
}
getProDataStore() {
return this.proDataStore
}
+1
View File
@@ -3,6 +3,7 @@
*/
import ApiStatusCodes from '../api/ApiStatusCodes'
import { IHashMapGeneric } from '../models/ICacheGeneric'
import CaptainConstants from '../utils/CaptainConstants'
import DataStore from './DataStore'
+247
View File
@@ -0,0 +1,247 @@
import configstore = require('configstore')
import ApiStatusCodes from '../api/ApiStatusCodes'
import { ProjectDefinition } from '../models/ProjectDefinition'
import Utils from '../utils/Utils'
import AppsDataStore from './AppsDataStore'
const PROJECTS_DEFINITIONS = 'projectsDefinitions'
function isNameAllowed(name: string) {
const isNameFormattingOk =
!!name &&
name.length < 50 &&
/^[a-z]/.test(name) &&
/[a-z0-9]$/.test(name) &&
/^[a-z0-9\-]+$/.test(name) &&
name.indexOf('--') < 0
return isNameFormattingOk && ['captain', 'root'].indexOf(name) < 0
}
function isValidUUID(uuid: string | undefined): boolean {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
return !!uuid && uuidRegex.test(uuid)
}
class ProjectsDataStore {
constructor(
private data: configstore,
private appsDataStore: AppsDataStore
) {}
saveProject(projectId: string, project: ProjectDefinition) {
const self = this
projectId = `${projectId || ''}`.trim()
return Promise.resolve()
.then(function () {
return self.getAllProjects()
})
.then(function (allProjects) {
project.name = `${project.name || ''}`.trim()
project.id = `${project.id || ''}`.trim()
project.description = `${project.description || ''}`.trim()
project.parentProjectId = `${
project.parentProjectId || ''
}`.trim()
if (!isNameAllowed(project.name)) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Project name is not allowed'
)
}
if (project.id !== projectId) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Project ID does not match'
)
}
if (!isValidUUID(project.id)) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Project ID is not a valid UUID'
)
}
if (project.parentProjectId) {
if (project.parentProjectId === project.id) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Parent Project ID cannot be the same as the project ID'
)
}
if (!isValidUUID(project.parentProjectId)) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Parent Project ID is not a valid UUID'
)
}
if (
!allProjects.some(
(p) => p.id === project.parentProjectId
)
) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Parent Project ID does not exist'
)
}
}
const projectToSave: ProjectDefinition = {
id: project.id,
name: project.name,
parentProjectId: project.parentProjectId,
description: project.description,
}
self.data.set(
`${PROJECTS_DEFINITIONS}.${projectId}`,
projectToSave
)
return projectToSave
})
}
getAllProjects(): Promise<ProjectDefinition[]> {
const self = this
return Promise.resolve()
.then(function () {
return self.data.get(PROJECTS_DEFINITIONS)
})
.then(function (projects) {
projects = projects || {}
return Object.keys(projects).map((key) => projects[key]) || []
})
}
getProject(projectId: string): Promise<ProjectDefinition> {
const self = this
projectId = `${projectId || ''}`.trim()
return Promise.resolve()
.then(function () {
return self.data.get(`${PROJECTS_DEFINITIONS}.${projectId}`) as
| ProjectDefinition
| undefined
})
.then(function (project) {
if (!project) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Project not found'
)
}
return project
})
}
organizeFromTheLeafsToRoot(input: ProjectDefinition[]) {
const projectMap = new Map<string, ProjectDefinition>()
input.forEach((project) => projectMap.set(project.id, project))
// Function to recursively organize projects
const organizeProjects = (projectId: string): ProjectDefinition[] => {
const project = projectMap.get(projectId)
if (!project) return []
const children = Array.from(projectMap.values())
.filter((p) => p.parentProjectId === projectId)
// just to make this deterministic for testing!
.sort((a, b) => b.id.localeCompare(a.id))
const organizedChildren = children
.map((child) => organizeProjects(child.id))
.flat()
return [project, ...organizedChildren]
}
const rootProjects = input
.filter((project) => !project.parentProjectId)
// just to make this deterministic for testing!
.sort((a, b) => b.id.localeCompare(a.id))
return rootProjects
.flatMap((root) => organizeProjects(root.id))
.reverse()
}
deleteProjects(projectIds: string[]) {
const self = this
projectIds = projectIds || []
projectIds = projectIds.map((it) => it.trim()).filter((it) => !!it)
return Promise.resolve()
.then(function () {
return self.getAllProjects()
})
.then(function (allProjects) {
allProjects = self.organizeFromTheLeafsToRoot(allProjects)
allProjects = allProjects.filter((project) =>
projectIds.includes(project.id)
)
const promises: Promise<any>[] = allProjects.map((p) =>
self.deleteProject(p.id)
)
return promises.reduce((accumulatedPromise, currentPromise) => {
return accumulatedPromise.then(() => currentPromise)
}, Promise.resolve())
})
}
deleteProject(projectId: string) {
const self = this
projectId = `${projectId || ''}`.trim()
return Promise.resolve()
.then(function () {
// dumb configstore needs some time to store the file!!!
// otherwise (in case multiple deletes), the child deletion is not committed yet
return Utils.getDelayedPromise(500)
})
.then(function () {
return self.getProject(projectId)
})
.then(function (project) {
// project exists
return self.appsDataStore.getAppDefinitions()
})
.then(function (appsAll) {
const apps = Object.keys(appsAll).map((key) => appsAll[key])
if (apps.some((app) => app.projectId === projectId)) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Project is not empty (has apps)'
)
}
})
.then(function () {
return self.getAllProjects()
})
.then(function (allProjects) {
if (allProjects.some((p) => p.parentProjectId === projectId)) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Project is not empty (has sub projects)'
)
}
return self.data.delete(`${PROJECTS_DEFINITIONS}.${projectId}`)
})
}
}
export default ProjectsDataStore
+4 -1
View File
@@ -15,7 +15,10 @@ const DEFAULT_DOCKER_REGISTRY_ID = 'defaultDockerRegId'
class RegistriesDataStore {
private encryptor: CaptainEncryptor
constructor(private data: configstore, public namepace: string) {}
constructor(
private data: configstore,
public namepace: string
) {}
setEncryptor(encryptor: CaptainEncryptor) {
this.encryptor = encryptor
+24 -13
View File
@@ -1,20 +1,30 @@
import Base64Provider = require('js-base64')
import Docker = require('dockerode')
import { v4 as uuid } from 'uuid'
import {
IAppDef,
IAppEnvVar,
IAppPort,
IAppVolume,
} from '../models/AppDefinition'
import { DockerAuthObj, DockerRegistryConfig } from '../models/DockerAuthObj'
import { DockerSecret } from '../models/DockerSecret'
import DockerService from '../models/DockerService'
import { IHashMapGeneric } from '../models/ICacheGeneric'
import {
IDockerApiPort,
IDockerContainerResource,
PreDeployFunction,
VolumesTypes,
} from '../models/OtherTypes'
import { ServerDockerInfo } from '../models/ServerDockerInfo'
import BuildLog from '../user/BuildLog'
import CaptainConstants from '../utils/CaptainConstants'
import EnvVars from '../utils/EnvVars'
import Logger from '../utils/Logger'
import Utils from '../utils/Utils'
import Dockerode = require('dockerode')
// @ts-ignore
// @ts-expect-error "TODO"
import dockerodeUtils = require('dockerode/lib/util')
const Base64 = Base64Provider.Base64
@@ -1425,11 +1435,11 @@ class DockerApi {
switch (updateOrder) {
case IDockerUpdateOrders.AUTO:
const existingVols =
updatedData.TaskTemplate.ContainerSpec.Mounts ||
[]
updatedData.UpdateConfig.Order =
existingVols.length > 0
(
updatedData.TaskTemplate.ContainerSpec
.Mounts || []
).length > 0
? 'stop-first'
: 'start-first'
break
@@ -1440,6 +1450,7 @@ class DockerApi {
updatedData.UpdateConfig.Order = 'stop-first'
break
default:
// eslint-disable-next-line
const neverHappens: never = updateOrder
throw new Error(
`Unknown update order! ${updateOrder}${neverHappens}`
@@ -1687,14 +1698,14 @@ const connectionParams: Docker.DockerOptions =
socketPath: CaptainConstants.dockerSocketPath,
}
: dockerApiAddressSplited.length === 2
? {
host: dockerApiAddressSplited[0],
port: Number(dockerApiAddressSplited[1]),
}
: {
host: `${dockerApiAddressSplited[0]}:${dockerApiAddressSplited[1]}`,
port: Number(dockerApiAddressSplited[2]),
}
? {
host: dockerApiAddressSplited[0],
port: Number(dockerApiAddressSplited[1]),
}
: {
host: `${dockerApiAddressSplited[0]}:${dockerApiAddressSplited[1]}`,
port: Number(dockerApiAddressSplited[2]),
}
connectionParams.version = CaptainConstants.configs.dockerApiVersion
const dockerApiInstance = new DockerApi(connectionParams)
+1
View File
@@ -1,6 +1,7 @@
import { Response } from 'express'
import { UserInjected } from '../models/InjectionInterfaces'
import { UserManager } from '../user/UserManager'
import { IAppDef } from '../models/AppDefinition'
class InjectionExtractor {
static extractUserFromInjected(res: Response) {
+1
View File
@@ -13,6 +13,7 @@ import { UserManagerProvider } from '../user/UserManagerProvider'
import CaptainConstants from '../utils/CaptainConstants'
import Logger from '../utils/Logger'
import InjectionExtractor from './InjectionExtractor'
import { IAppDef } from '../models/AppDefinition'
const dockerApi = DockerApiProvider.get()
+17 -14
View File
@@ -1,18 +1,20 @@
type IAllAppDefinitions = IHashMapGeneric<IAppDef>
import { IHashMapGeneric } from './ICacheGeneric'
interface IAppEnvVar {
export type IAllAppDefinitions = IHashMapGeneric<IAppDef>
export interface IAppEnvVar {
key: string
value: string
}
interface IAppVolume {
export interface IAppVolume {
containerPath: string
volumeName?: string
hostPath?: string
mode?: string
}
interface IAppPort {
export interface IAppPort {
containerPort: number
hostPort: number
protocol?: 'udp' | 'tcp'
@@ -20,7 +22,7 @@ interface IAppPort {
publishMode?: 'ingress' | 'host'
}
interface RepoInfo {
export interface RepoInfo {
repo: string
branch: string
user: string
@@ -28,7 +30,7 @@ interface RepoInfo {
password: string
}
interface RepoInfoEncrypted {
export interface RepoInfoEncrypted {
repo: string
branch: string
user: string
@@ -36,23 +38,24 @@ interface RepoInfoEncrypted {
passwordEncrypted: string
}
interface IAppVersion {
export interface IAppVersion {
version: number
deployedImageName?: string // empty if the deploy is not completed
timeStamp: string
gitHash: string | undefined
}
interface IAppCustomDomain {
export interface IAppCustomDomain {
publicDomain: string
hasSsl: boolean
}
interface IAppTag {
export interface IAppTag {
tagName: string
}
interface IAppDefinitionBase {
export interface IAppDefinitionBase {
projectId?: string | undefined
description: string
deployedVersion: number
notExposeAsWebApp: boolean
@@ -78,18 +81,18 @@ interface IAppDefinitionBase {
appDeployTokenConfig?: AppDeployTokenConfig
}
interface IHttpAuth {
export interface IHttpAuth {
user: string
password?: string
passwordHashed?: string
}
interface AppDeployTokenConfig {
export interface AppDeployTokenConfig {
enabled: boolean
appDeployToken?: string
}
interface IAppDef extends IAppDefinitionBase {
export interface IAppDef extends IAppDefinitionBase {
appPushWebhook?: {
tokenVersion: string
repoInfo: RepoInfo
@@ -100,7 +103,7 @@ interface IAppDef extends IAppDefinitionBase {
isAppBuilding?: boolean
}
interface IAppDefSaved extends IAppDefinitionBase {
export interface IAppDefSaved extends IAppDefinitionBase {
appPushWebhook:
| {
tokenVersion: string
+1 -1
View File
@@ -5,7 +5,7 @@ export interface IAutomatedCleanupConfigs {
}
export class AutomatedCleanupConfigsCleaner {
static cleanup(instance: IAutomatedCleanupConfigs) {
static sanitizeInput(instance: IAutomatedCleanupConfigs) {
return {
mostRecentLimit:
Number(instance.mostRecentLimit) > 0
+2
View File
@@ -1,3 +1,5 @@
import { ServerDockerInfo } from './ServerDockerInfo'
export interface RestoringNode {
oldIp: string
newIp: string
+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
}
+2 -2
View File
@@ -1,4 +1,4 @@
interface DockerAuthObj {
export interface DockerAuthObj {
serveraddress: string
username: string
password: string
@@ -17,7 +17,7 @@ interface DockerAuthObj {
}
}
*/
interface DockerRegistryConfig {
export interface DockerRegistryConfig {
[serveraddress: string]: {
username: string
password: string
+1 -1
View File
@@ -1,4 +1,4 @@
interface DockerSecret {
export interface DockerSecret {
secretName: string
secretId: string
}
+1 -1
View File
@@ -1,3 +1,3 @@
interface IHashMapGeneric<T> {
export interface IHashMapGeneric<T> {
[id: string]: T
}
+1 -1
View File
@@ -1,4 +1,4 @@
interface ICaptainDefinition {
export interface ICaptainDefinition {
schemaVersion: number
dockerfileLines?: string[]
dockerfilePath?: string
+3 -1
View File
@@ -1,4 +1,6 @@
interface IImageSource {
import { RepoInfo } from './AppDefinition'
export interface IImageSource {
uploadedTarPathSource?: { uploadedTarPath: string; gitHash: string }
captainDefinitionContentSource?: {
captainDefinitionContent: string
+1 -1
View File
@@ -1,4 +1,4 @@
interface IServerBlockDetails {
export interface IServerBlockDetails {
hasSsl: boolean
forceSsl: boolean
websocketSupport: boolean
+1 -1
View File
@@ -1,4 +1,4 @@
class NetDataInfo {
export class NetDataInfo {
public isEnabled: boolean
public data: {
smtp: {
+2
View File
@@ -1,3 +1,5 @@
import { IAppDef } from './AppDefinition'
export type CaptainError = {
captainErrorType: number
apiMessage: string
+6
View File
@@ -0,0 +1,6 @@
export interface ProjectDefinition {
id: string
name: string
description: string
parentProjectId?: string
}
+1 -1
View File
@@ -1,4 +1,4 @@
interface ServerDockerInfo {
export interface ServerDockerInfo {
nodeId: string
type: 'manager' | 'worker'
isLeader: 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
+133
View File
@@ -0,0 +1,133 @@
import express = require('express')
import { v4 as uuid } from 'uuid'
import ApiStatusCodes from '../../api/ApiStatusCodes'
import BaseApi from '../../api/BaseApi'
import InjectionExtractor from '../../injection/InjectionExtractor'
import { ProjectDefinition } from '../../models/ProjectDefinition'
import Logger from '../../utils/Logger'
const router = express.Router()
router.post('/register/', function (req, res, next) {
const dataStore =
InjectionExtractor.extractUserFromInjected(res).user.dataStore
const projectName = `${req.body.name || ''}`.trim()
const parentProjectId = `${req.body.parentProjectId || ''}`.trim()
const description = `${req.body.description || ''}`.trim()
Promise.resolve()
.then(function () {
const projectId = uuid()
return dataStore.getProjectsDataStore().saveProject(projectId, {
id: projectId,
name: projectName,
parentProjectId: parentProjectId,
description: description,
})
})
.then(function (project) {
Logger.d(`Project created: ${projectName}`)
const resp = new BaseApi(
ApiStatusCodes.STATUS_OK,
`Project created: ${projectName}`
)
resp.data = project
res.send(resp)
})
.catch(ApiStatusCodes.createCatcher(res))
})
router.post('/delete/', function (req, res, next) {
const dataStore =
InjectionExtractor.extractUserFromInjected(res).user.dataStore
const projectIds = (req.body.projectIds || []).map((id: string) =>
`${id}`.trim()
)
Promise.resolve()
.then(function () {
return dataStore //
.getProjectsDataStore()
.deleteProjects(projectIds)
})
.then(function () {
Logger.d(`Projects are deleted: ${projectIds}`)
res.send(new BaseApi(ApiStatusCodes.STATUS_OK, 'Project deleted'))
})
.catch(ApiStatusCodes.createCatcher(res))
})
router.post('/update/', function (req, res, next) {
const dataStore =
InjectionExtractor.extractUserFromInjected(res).user.dataStore
const projectDefinition = req.body.projectDefinition as
| ProjectDefinition
| undefined
Promise.resolve()
.then(function () {
if (!projectDefinition) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Project Definition is not provided'
)
}
projectDefinition.id = `${projectDefinition.id || ''}`
projectDefinition.name = `${projectDefinition.name || ''}`
projectDefinition.parentProjectId = `${
projectDefinition.parentProjectId || ''
}`
projectDefinition.description = `${
projectDefinition.description || ''
}`
if (!projectDefinition.id) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Project ID is not provided'
)
}
return dataStore
.getProjectsDataStore()
.saveProject(projectDefinition.id, {
id: projectDefinition.id,
name: projectDefinition.name,
parentProjectId: projectDefinition.parentProjectId,
description: projectDefinition.description,
})
})
.then(function () {
Logger.d(`Project is saved: ${projectDefinition?.name}`)
res.send(new BaseApi(ApiStatusCodes.STATUS_OK, 'Project Saved'))
})
.catch(ApiStatusCodes.createCatcher(res))
})
// Get All Projects
router.get('/', function (req, res, next) {
const dataStore =
InjectionExtractor.extractUserFromInjected(res).user.dataStore
dataStore
.getProjectsDataStore()
.getAllProjects()
.then(function (projects) {
const baseApi = new BaseApi(
ApiStatusCodes.STATUS_OK,
'Projects are retrieved.'
)
baseApi.data = {
projects: projects,
}
res.send(baseApi)
})
.catch(ApiStatusCodes.createCatcher(res))
})
export default router
+4
View File
@@ -9,9 +9,11 @@ import Utils from '../../utils/Utils'
import AppsRouter from './apps/AppsRouter'
import OneClickAppRouter from './oneclick/OneClickAppRouter'
import ProRouter from './pro/ProRouter'
import ProjectsRouter from './ProjectsRouter'
import RegistriesRouter from './registeries/RegistriesRouter'
import SystemRouter from './system/SystemRouter'
import onFinished = require('on-finished')
import { IHashMapGeneric } from '../../models/ICacheGeneric'
const router = express.Router()
@@ -124,6 +126,8 @@ router.post('/changepassword/', function (req, res, next) {
router.use('/apps/', AppsRouter)
router.use('/projects/', ProjectsRouter)
router.use('/oneclick/', OneClickAppRouter)
router.use('/registries/', RegistriesRouter)
@@ -7,6 +7,7 @@ import CaptainManager from '../../../../user/system/CaptainManager'
import CaptainConstants from '../../../../utils/CaptainConstants'
import Logger from '../../../../utils/Logger'
import Utils from '../../../../utils/Utils'
import { IAppDef, AppDeployTokenConfig } from '../../../../models/AppDefinition'
const router = express.Router()
@@ -191,6 +192,7 @@ router.post('/register/', function (req, res, next) {
InjectionExtractor.extractUserFromInjected(res).user.serviceManager
const appName = req.body.appName as string
const projectId = `${req.body.projectId || ''}`
const hasPersistentData = !!req.body.hasPersistentData
const isDetachedBuild = !!req.query.detached
@@ -198,9 +200,18 @@ router.post('/register/', function (req, res, next) {
Logger.d(`Registering app started: ${appName}`)
dataStore
.getAppsDataStore()
.registerAppDefinition(appName, hasPersistentData)
return Promise.resolve()
.then(function () {
if (projectId) {
return dataStore.getProjectsDataStore().getProject(projectId)
// if project is not found, it will throw an error
}
})
.then(function () {
return dataStore
.getAppsDataStore()
.registerAppDefinition(appName, projectId, hasPersistentData)
})
.then(function () {
appCreated = true
})
@@ -322,6 +333,7 @@ router.post('/update/', function (req, res, next) {
InjectionExtractor.extractUserFromInjected(res).user.serviceManager
const appName = req.body.appName
const projectId = req.body.projectId
const nodeId = req.body.nodeId
const captainDefinitionRelativeFilePath =
req.body.captainDefinitionRelativeFilePath
@@ -384,13 +396,28 @@ router.post('/update/', function (req, res, next) {
) {
res.send(
new BaseApi(
ApiStatusCodes.STATUS_ERROR_GENERIC,
ApiStatusCodes.ILLEGAL_PARAMETER,
'Missing required Github/BitBucket/Gitlab field'
)
)
return
}
if (
repoInfo &&
repoInfo.sshKey &&
repoInfo.sshKey.indexOf('ENCRYPTED') > 0 &&
!CaptainConstants.configs.disableEncryptedCheck
) {
res.send(
new BaseApi(
ApiStatusCodes.ILLEGAL_PARAMETER,
'You cannot use encrypted SSH keys'
)
)
return
}
if (
repoInfo &&
repoInfo.sshKey &&
@@ -405,6 +432,7 @@ router.post('/update/', function (req, res, next) {
serviceManager
.updateAppDefinition(
appName,
projectId,
description,
Number(instanceCount),
captainDefinitionRelativeFilePath,
+7 -5
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()
@@ -221,13 +223,13 @@ router.get('/diskcleanup/', function (req, res, next) {
})
router.post('/diskcleanup/', function (req, res, next) {
const configs = AutomatedCleanupConfigsCleaner.cleanup({
mostRecentLimit: req.body.mostRecentLimit,
cronSchedule: req.body.cronSchedule,
timezone: req.body.timezone,
})
return Promise.resolve()
.then(function () {
const configs = AutomatedCleanupConfigsCleaner.sanitizeInput({
mostRecentLimit: req.body.mostRecentLimit,
cronSchedule: req.body.cronSchedule,
timezone: req.body.timezone,
})
return CaptainManager.get()
.getDiskCleanupManager()
.setConfig(configs)
+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
+1
View File
@@ -6,6 +6,7 @@ import CaptainConstants from '../utils/CaptainConstants'
import EnvVar from '../utils/EnvVars'
import Logger from '../utils/Logger'
import bcrypt = require('bcryptjs')
import { IHashMapGeneric } from '../models/ICacheGeneric'
const captainDefaultPassword = EnvVar.DEFAULT_PASSWORD || 'captain42'
+5 -1
View File
@@ -2,6 +2,7 @@ import ApiStatusCodes from '../api/ApiStatusCodes'
import DataStore from '../datastore/DataStore'
import RegistriesDataStore from '../datastore/RegistriesDataStore'
import DockerApi from '../docker/DockerApi'
import { DockerAuthObj, DockerRegistryConfig } from '../models/DockerAuthObj'
import {
IRegistryInfo,
IRegistryType,
@@ -14,7 +15,10 @@ import BuildLog from './BuildLog'
class DockerRegistryHelper {
private registriesDataStore: RegistriesDataStore
constructor(dataStore: DataStore, private dockerApi: DockerApi) {
constructor(
dataStore: DataStore,
private dockerApi: DockerApi
) {
this.registriesDataStore = dataStore.getRegistriesDataStore()
}
+6 -3
View File
@@ -57,9 +57,12 @@ export default class FeatureFlags {
Logger.e(error)
})
.then(function () {
setTimeout(() => {
self.refreshFeatureFlags()
}, 1000 * 3600 * 19.3) // some random hour to avoid constant traffic
setTimeout(
() => {
self.refreshFeatureFlags()
},
1000 * 3600 * 19.3
) // some random hour to avoid constant traffic
})
}
}
+4
View File
@@ -40,7 +40,11 @@ import tar = require('tar')
import path = require('path')
import ApiStatusCodes from '../api/ApiStatusCodes'
import DockerApi from '../docker/DockerApi'
import { IAppEnvVar } from '../models/AppDefinition'
import { IBuiltImage } from '../models/IBuiltImage'
import { IHashMapGeneric } from '../models/ICacheGeneric'
import { ICaptainDefinition } from '../models/ICaptainDefinition'
import { IImageSource } from '../models/IImageSource'
import { AnyError } from '../models/OtherTypes'
import CaptainConstants from '../utils/CaptainConstants'
import GitHelper from '../utils/GitHelper'
+26
View File
@@ -1,6 +1,19 @@
import ApiStatusCodes from '../api/ApiStatusCodes'
import DataStore from '../datastore/DataStore'
import DockerApi, { IDockerUpdateOrders } from '../docker/DockerApi'
import {
AppDeployTokenConfig,
IAppDef,
IAppEnvVar,
IAppPort,
IAppTag,
IAppVolume,
IHttpAuth,
RepoInfo,
} from '../models/AppDefinition'
import { DockerAuthObj } from '../models/DockerAuthObj'
import { IHashMapGeneric } from '../models/ICacheGeneric'
import { IImageSource } from '../models/IImageSource'
import { PreDeployFunction } from '../models/OtherTypes'
import CaptainConstants from '../utils/CaptainConstants'
import Logger from '../utils/Logger'
@@ -587,6 +600,7 @@ class ServiceManager {
updateAppDefinition(
appName: string,
projectId: string,
description: string,
instanceCount: number,
captainDefinitionRelativeFilePath: string,
@@ -631,6 +645,16 @@ class ServiceManager {
}
return Promise.resolve()
.then(function () {
projectId = `${projectId || ''}`.trim()
if (projectId) {
return dataStore
.getProjectsDataStore()
.getProject(projectId)
// if project is not found, it will throw an error
}
})
.then(function () {
return self.ensureNotBuilding(appName)
})
@@ -715,6 +739,7 @@ class ServiceManager {
.getAppsDataStore()
.updateAppDefinitionInDb(
appName,
projectId,
description,
instanceCount,
captainDefinitionRelativeFilePath,
@@ -755,6 +780,7 @@ class ServiceManager {
.getAppsDataStore()
.updateAppDefinitionInDb(
appName,
existingAppDefinition.projectId,
existingAppDefinition.description,
existingAppDefinition.instanceCount,
existingAppDefinition.captainDefinitionRelativeFilePath,
+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()
})
}
}
+1
View File
@@ -1,4 +1,5 @@
import ApiStatusCodes from '../api/ApiStatusCodes'
import { IHashMapGeneric } from '../models/ICacheGeneric'
import CaptainConstants from '../utils/CaptainConstants'
import { UserManager } from './UserManager'
+4 -1
View File
@@ -4,7 +4,10 @@ import { TwoFactorAuthResponse } from '../../models/IProFeatures'
import ProManager from './ProManager'
export default class OtpAuthenticator {
constructor(private dataStore: DataStore, private proManager: ProManager) {}
constructor(
private dataStore: DataStore,
private proManager: ProManager
) {}
set2fa(
doEnable: boolean,
+14 -8
View File
@@ -1,13 +1,16 @@
import SshClientImport = require('ssh2')
import { exec } from 'child_process'
import * as fs from 'fs-extra'
import * as moment from 'moment'
import moment from 'moment'
import * as path from 'path'
import * as tar from 'tar'
import ApiStatusCodes from '../../api/ApiStatusCodes'
import DockerApi from '../../docker/DockerApi'
import DockerUtils from '../../docker/DockerUtils'
import { IAppDefSaved } from '../../models/AppDefinition'
import { BackupMeta, RestoringInfo } from '../../models/BackupMeta'
import { IHashMapGeneric } from '../../models/ICacheGeneric'
import { ServerDockerInfo } from '../../models/ServerDockerInfo'
import CaptainConstants from '../../utils/CaptainConstants'
import Logger from '../../utils/Logger'
import Utils from '../../utils/Utils'
@@ -717,13 +720,16 @@ export default class BackupManager {
)}-${now.valueOf()}`}${`-ip-${mainIP}.tar`}`
fs.moveSync(tarFilePath, newName)
setTimeout(() => {
try {
fs.removeSync(newName)
} catch (err) {
// nom nom
}
}, 1000 * 3600 * 2)
setTimeout(
() => {
try {
fs.removeSync(newName)
} catch (err) {
// nom nom
}
},
1000 * 3600 * 2
)
return Authenticator.getAuthenticator(
namespace
+1
View File
@@ -25,6 +25,7 @@ import LoadBalancerManager from './LoadBalancerManager'
import SelfHostedDockerRegistry from './SelfHostedDockerRegistry'
import request = require('request')
import fs = require('fs-extra')
import { NetDataInfo } from '../../models/NetDataInfo'
const DEBUG_SALT = 'THIS IS NOT A REAL CERTIFICATE'
+4 -1
View File
@@ -8,7 +8,10 @@ import Logger from '../../utils/Logger'
export default class DiskCleanupManager {
private job: CronJob | undefined
constructor(private dataStore: DataStore, private dockerApi: DockerApi) {
constructor(
private dataStore: DataStore,
private dockerApi: DockerApi
) {
//
}
+10 -6
View File
@@ -6,6 +6,7 @@ import { v4 as uuid } from 'uuid'
import ApiStatusCodes from '../../api/ApiStatusCodes'
import DataStore from '../../datastore/DataStore'
import DockerApi from '../../docker/DockerApi'
import { IServerBlockDetails } from '../../models/IServerBlockDetails'
import LoadBalancerInfo from '../../models/LoadBalancerInfo'
import { AnyError } from '../../models/OtherTypes'
import CaptainConstants from '../../utils/CaptainConstants'
@@ -850,12 +851,15 @@ class LoadBalancerManager {
// this random schedule helps to avoid retrying at the same time of
// the day in case if that's our super high traffic time
setTimeout(function () {
self.renewAllCertsAndReload() //
.catch((err) => {
Logger.e(err)
})
}, 1000 * 3600 * 20.3)
setTimeout(
function () {
self.renewAllCertsAndReload() //
.catch((err) => {
Logger.e(err)
})
},
1000 * 3600 * 20.3
)
return self.certbotManager
.renewAllCerts() //
-4
View File
@@ -96,7 +96,6 @@ export default class ApacheMd5 {
let final = crypto
.createHash('md5')
.update(password + salt + password, 'ascii')
//@ts-ignore
.digest(DIGEST_ENCODING)
for (let pl = password.length; pl > 0; pl -= 16) {
@@ -111,11 +110,9 @@ export default class ApacheMd5 {
}
}
//@ts-ignore
final = crypto
.createHash('md5')
.update(ctx, 'ascii')
//@ts-ignore
.digest(DIGEST_ENCODING)
// 1000 loop.
@@ -147,7 +144,6 @@ export default class ApacheMd5 {
final = crypto
.createHash('md5')
.update(ctxl, 'ascii')
//@ts-ignore
.digest(DIGEST_ENCODING)
}
+5 -2
View File
@@ -17,7 +17,7 @@ const CONSTANT_FILE_OVERRIDE_USER =
const configs = {
publishedNameOnDockerHub: 'caprover/caprover',
version: '1.12.0',
version: '1.13.0',
defaultMaxLogSize: '512m',
@@ -58,6 +58,9 @@ const configs = {
certbotImageName: 'caprover/certbot-sleeping:v2.11.0',
certbotCertCommandRules: undefined as CertbotCertCommandRule[] | undefined,
// this is added in 1.13 just as a safety - remove this after 1.14
disableEncryptedCheck: false,
}
export interface CertbotCertCommandRule {
@@ -205,7 +208,7 @@ function overrideConfigFromFile(fileName: string) {
}
console.log(`Overriding ${prop} from ${fileName}`)
// @ts-ignore
// @ts-expect-error "this actually works"
configs[prop] = overridingValuesConfigs[prop]
}
}
+22 -2
View File
@@ -1,5 +1,5 @@
import externalIp = require('public-ip')
import DockerApi from '../docker/DockerApi'
import { IAppEnvVar, IAppPort } from '../models/AppDefinition'
import BackupManager from '../user/system/BackupManager'
import CaptainConstants from './CaptainConstants'
import EnvVar from './EnvVars'
@@ -198,6 +198,11 @@ function printTroubleShootingUrl() {
let myIp4: string
async function initializeExternalIp() {
const externalIp = await import('public-ip')
return externalIp
}
export function install() {
const backupManger = new BackupManager()
@@ -222,11 +227,26 @@ export function install() {
return checkSystemReq()
})
.then(function () {
return initializeExternalIp()
})
.then(function (externalIp) {
if (EnvVar.MAIN_NODE_IP_ADDRESS) {
return EnvVar.MAIN_NODE_IP_ADDRESS
}
return externalIp.v4()
try {
const externalIpFetched = externalIp.publicIpv4()
if (externalIpFetched) {
return externalIpFetched
}
} catch (error) {
console.error(
'Defaulting to 127.0.0.1 - Error retrieving IP address:',
error
)
}
return '127.0.0.1'
})
.then(function (ip4) {
if (!ip4) {
+1 -1
View File
@@ -1,7 +1,7 @@
import * as childProcess from 'child_process'
import * as fs from 'fs-extra'
import * as path from 'path'
import * as git from 'simple-git/promise'
import git from 'simple-git'
import * as util from 'util'
import * as uuid from 'uuid'
import CaptainConstants from './CaptainConstants'
+1 -1
View File
@@ -1,4 +1,4 @@
import * as moment from 'moment'
import moment from 'moment'
import { AnyError } from '../models/OtherTypes'
import CaptainConstants from './CaptainConstants'
+3
View File
@@ -2,6 +2,7 @@ import * as fs from 'fs-extra'
import * as path from 'path'
import DataStore from '../datastore/DataStore'
import DockerApi from '../docker/DockerApi'
import { IAppVersion } from '../models/AppDefinition'
import { IRegistryTypes } from '../models/IRegistryInfo'
import Authenticator from '../user/Authenticator'
import CaptainConstants from './CaptainConstants'
@@ -206,6 +207,7 @@ export default class MigrateCaptainDuckDuck {
.then(function () {
return appStore.registerAppDefinition(
appName,
'',
!!app.hasPersistentData
)
})
@@ -322,6 +324,7 @@ export default class MigrateCaptainDuckDuck {
return appStore.updateAppDefinitionInDb(
appName,
'',
'',
Number(app.instanceCount),
CaptainConstants.defaultCaptainDefinitionPath,
app.envVars || [],
@@ -1,5 +1,6 @@
import request = require('request')
import ApiStatusCodes from '../api/ApiStatusCodes'
import { IHashMapGeneric } from '../models/ICacheGeneric'
import { ITemplate } from '../models/OtherTypes'
import Logger from './Logger'
+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, '')
+23
View File
@@ -0,0 +1,23 @@
###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'}
+36
View File
@@ -0,0 +1,36 @@
###CapRoverTheme.name:
Teal Zen
###CapRoverTheme.content:
{
algorithm: isDarkMode ? darkAlgorithm : defaultAlgorithm,
components: {
Menu: {
itemBg: isDarkMode ? '#151515' : '#fafafa',
},
Layout: {
headerBg: '#1a362f',
},
},
token: {
colorPrimary: '#008264',
colorLink: '#02bf94',
fontFamily: `'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'`,
borderRadius: 20,
lineWidth: 2,
colorBgLayout: '#dadada',
},
}
###CapRoverTheme.headEmbed:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet">
###CapRoverTheme.extra:
{}
+58
View File
@@ -0,0 +1,58 @@
###CapRoverTheme.name:
Robotic
###CapRoverTheme.content:
{
algorithm: isDarkMode ? darkAlgorithm : defaultAlgorithm,
components: {
Menu: {
itemPadding: '0 20px',
itemHeight: '40px',
height: '56px',
colorText: '#fff',
colorBg: '#3f3f3f',
colorSubBg: '#4f4f4f',
colorItemBg: '#5f5f5f',
colorItemBgActive: '#7f7bff',
colorItemText: '#fff',
colorItemTextActive: '#fff',
colorItemIcon: '#fff',
colorItemIconActive: '#fff',
colorSider: '#3f3f3f',
colorSiderTitle: '#fff',
colorSiderTitleIcon: '#fff',
colorSiderTrigger: '#fff',
colorSiderTriggerIcon: '#fff',
colorSiderTriggerIconActive: '#7f7bff',
},
Layout: {
headerBg: '#3f3f3f',
footerBg: '#4f4f4f',
siderWidth: '256px',
layoutBg: '#f0f2f5',
contentBg: '#fff',
colorPrimary: '#7f7bff',
colorPrimaryBg: '#7f7bff',
colorInfo: '#7f7bff',
},
},
token: {
borderRadius: 2,
colorPrimary: '#7f7bff',
colorLink: '#2672c9',
fontFamily: `'Oxanium', -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'`,
},
}
###CapRoverTheme.headEmbed:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Oxanium:wght@200..800&display=swap" rel="stylesheet">
###CapRoverTheme.extra:
{}
+145
View File
@@ -0,0 +1,145 @@
import configstore from 'configstore'
import AppsDataStore from '../src/datastore/AppsDataStore'
import ProjectsDataStore from '../src/datastore/ProjectsDataStore'
import { ProjectDefinition } from '../src/models/ProjectDefinition'
describe('ProjectsDataStore', () => {
let projectsDataStore: ProjectsDataStore
beforeEach(() => {
// Mock the configstore and AppsDataStore
const mockConfigstore = new configstore('test')
const mockAppsDataStore = {} as AppsDataStore
projectsDataStore = new ProjectsDataStore(
mockConfigstore,
mockAppsDataStore
)
})
describe('organizeFromTheLeafsToRoot', () => {
it('should correctly organize projects from leaves to root', () => {
const input = [
{
id: '1',
name: 'Root 1',
parentProjectId: '',
description: 'Root 1 desc',
},
{
id: '2',
name: 'Child 1',
parentProjectId: '1',
description: 'Child 1 desc',
},
{
id: '3',
name: 'Child 2',
parentProjectId: '1',
description: 'Child 2 desc',
},
{
id: '4',
name: 'Grandchild 1',
parentProjectId: '2',
description: 'Grandchild 1 desc',
},
{
id: '5',
name: 'Root 2',
parentProjectId: '',
description: 'Root 2 desc',
},
{
id: '6',
name: 'Child 3',
parentProjectId: '5',
description: 'Child 3 desc',
},
]
const expected = [
{
id: '4',
name: 'Grandchild 1',
parentProjectId: '2',
description: 'Grandchild 1 desc',
},
{
id: '2',
name: 'Child 1',
parentProjectId: '1',
description: 'Child 1 desc',
},
{
id: '3',
name: 'Child 2',
parentProjectId: '1',
description: 'Child 2 desc',
},
{
id: '1',
name: 'Root 1',
parentProjectId: '',
description: 'Root 1 desc',
},
{
id: '6',
name: 'Child 3',
parentProjectId: '5',
description: 'Child 3 desc',
},
{
id: '5',
name: 'Root 2',
parentProjectId: '',
description: 'Root 2 desc',
},
]
const result = projectsDataStore.organizeFromTheLeafsToRoot(input)
expect(result).toEqual(expected)
})
it('should handle empty input', () => {
const input: ProjectDefinition[] = []
const result = projectsDataStore.organizeFromTheLeafsToRoot(input)
expect(result).toEqual([])
})
it('should handle projects with no parent', () => {
const input = [
{
id: '1',
name: 'Root 1',
parentProjectId: '',
description: 'Root 1 desc',
},
{
id: '2',
name: 'Root 2',
parentProjectId: '',
description: 'Root 2 desc',
},
]
const expected = [
{
id: '1',
name: 'Root 1',
parentProjectId: '',
description: 'Root 1 desc',
},
{
id: '2',
name: 'Root 2',
parentProjectId: '',
description: 'Root 2 desc',
},
]
const result = projectsDataStore.organizeFromTheLeafsToRoot(input)
expect(result).toEqual(expected)
})
})
})
+6 -7
View File
@@ -1,16 +1,15 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"module": "Node16",
"moduleResolution": "Node16",
"strictNullChecks": true,
"outDir": "./built",
"noImplicitAny": true,
"sourceMap": true,
"allowJs": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"target": "es6"
"target": "ES2018"
},
"include": [
"./src/**/*"
]
}
"include": ["./src/**/*"]
}