From fa11945235c6a213b204eb7679176079d0882c32 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Mon, 8 Sep 2025 19:47:00 +1000 Subject: [PATCH] Introducing the Setup Wizard for creating the first user - no longer setup a default - still able to do that with env vars however --- backend/biome.json | 2 +- backend/internal/token.js | 115 +++++---- backend/internal/user.js | 103 ++++---- backend/lib/access.js | 276 ++++++++++------------ backend/lib/express/jwt-decode.js | 20 +- backend/lib/validator/api.js | 41 ++-- backend/package.json | 2 +- backend/routes/main.js | 5 +- backend/routes/tokens.js | 32 +-- backend/routes/users.js | 242 ++++++++++--------- backend/setup.js | 197 ++++++++------- backend/yarn.lock | 88 +++---- docker/docker-compose.ci.yml | 1 + docker/docker-compose.dev.yml | 2 + docs/package.json | 2 +- docs/src/advanced-config/index.md | 10 + docs/src/faq/index.md | 8 +- docs/src/guide/index.md | 13 +- docs/src/setup/index.md | 25 +- frontend/biome.json | 2 +- frontend/package.json | 2 +- frontend/src/Router.tsx | 7 +- frontend/src/api/backend/base.ts | 12 +- frontend/src/api/backend/createUser.ts | 17 +- frontend/src/api/backend/responseTypes.ts | 1 + frontend/src/locale/lang/en.json | 2 + frontend/src/locale/src/en.json | 6 + frontend/src/pages/Dashboard/index.tsx | 5 +- frontend/src/pages/Setup/index.module.css | 10 + frontend/src/pages/Setup/index.tsx | 191 +++++++++++++++ frontend/yarn.lock | 88 +++---- 31 files changed, 867 insertions(+), 660 deletions(-) create mode 100644 frontend/src/pages/Setup/index.module.css create mode 100644 frontend/src/pages/Setup/index.tsx diff --git a/backend/biome.json b/backend/biome.json index a7d7539f..10877ff3 100644 --- a/backend/biome.json +++ b/backend/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", + "$schema": "https://biomejs.dev/schemas/2.2.3/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/backend/internal/token.js b/backend/internal/token.js index f1d2b370..1935b16d 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -18,67 +18,66 @@ export default { * @param {String} [issuer] * @returns {Promise} */ - getTokenFromEmail: (data, issuer) => { + getTokenFromEmail: async (data, issuer) => { const Token = TokenModel(); data.scope = data.scope || "user"; data.expiry = data.expiry || "1d"; - return userModel + const user = await userModel .query() .where("email", data.identity.toLowerCase().trim()) .andWhere("is_deleted", 0) .andWhere("is_disabled", 0) - .first() - .then((user) => { - if (user) { - // Get auth - return authModel - .query() - .where("user_id", "=", user.id) - .where("type", "=", "password") - .first() - .then((auth) => { - if (auth) { - return auth.verifyPassword(data.secret).then((valid) => { - if (valid) { - if (data.scope !== "user" && _.indexOf(user.roles, data.scope) === -1) { - // The scope requested doesn't exist as a role against the user, - // you shall not pass. - throw new errs.AuthError(`Invalid scope: ${data.scope}`); - } + .first(); - // Create a moment of the expiry expression - const expiry = parseDatePeriod(data.expiry); - if (expiry === null) { - throw new errs.AuthError(`Invalid expiry time: ${data.expiry}`); - } + if (!user) { + throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); + } - return Token.create({ - iss: issuer || "api", - attrs: { - id: user.id, - }, - scope: [data.scope], - expiresIn: data.expiry, - }).then((signed) => { - return { - token: signed.token, - expires: expiry.toISOString(), - }; - }); - } - throw new errs.AuthError( - ERROR_MESSAGE_INVALID_AUTH, - ERROR_MESSAGE_INVALID_AUTH_I18N, - ); - }); - } - throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); - }); - } - throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); - }); + const auth = await authModel + .query() + .where("user_id", "=", user.id) + .where("type", "=", "password") + .first(); + + if (!auth) { + throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); + } + + const valid = await auth.verifyPassword(data.secret); + if (!valid) { + throw new errs.AuthError( + ERROR_MESSAGE_INVALID_AUTH, + ERROR_MESSAGE_INVALID_AUTH_I18N, + ); + } + + if (data.scope !== "user" && _.indexOf(user.roles, data.scope) === -1) { + // The scope requested doesn't exist as a role against the user, + // you shall not pass. + throw new errs.AuthError(`Invalid scope: ${data.scope}`); + } + + // Create a moment of the expiry expression + const expiry = parseDatePeriod(data.expiry); + if (expiry === null) { + throw new errs.AuthError(`Invalid expiry time: ${data.expiry}`); + } + + const signed = await Token.create({ + iss: issuer || "api", + attrs: { + id: user.id, + }, + scope: [data.scope], + expiresIn: data.expiry, + }); + + return { + token: signed.token, + expires: expiry.toISOString(), + }; }, /** @@ -88,7 +87,7 @@ export default { * @param {String} [data.scope] Only considered if existing token scope is admin * @returns {Promise} */ - getFreshToken: (access, data) => { + getFreshToken: async (access, data) => { const Token = TokenModel(); const thisData = data || {}; @@ -115,17 +114,17 @@ export default { } } - return Token.create({ + const signed = await Token.create({ iss: "api", scope: scope, attrs: token_attrs, expiresIn: thisData.expiry, - }).then((signed) => { - return { - token: signed.token, - expires: expiry.toISOString(), - }; }); + + return { + token: signed.token, + expires: expiry.toISOString(), + }; } throw new error.AssertionFailedError("Existing token contained invalid user data"); }, @@ -136,7 +135,7 @@ export default { */ getTokenFromUser: async (user) => { const expire = "1d"; - const Token = new TokenModel(); + const Token = TokenModel(); const expiry = parseDatePeriod(expire); const signed = await Token.create({ diff --git a/backend/internal/user.js b/backend/internal/user.js index 1c1f3a86..b9aa60e9 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -10,17 +10,20 @@ import internalToken from "./token.js"; const omissions = () => { return ["is_deleted"]; -} +}; -const DEFAULT_AVATAR = 'https://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=200&d=mp&r=g'; +const DEFAULT_AVATAR = gravatar.url("admin@example.com", { default: "mm" }); const internalUser = { /** + * Create a user can happen unauthenticated only once and only when no active users exist. + * Otherwise, a valid auth method is required. + * * @param {Access} access * @param {Object} data * @returns {Promise} */ - create: (access, data) => { + create: async (access, data) => { const auth = data.auth || null; delete data.auth; @@ -31,61 +34,43 @@ const internalUser = { data.is_disabled = data.is_disabled ? 1 : 0; } - return access - .can("users:create", data) - .then(() => { - data.avatar = gravatar.url(data.email, { default: "mm" }); - return userModel.query().insertAndFetch(data).then(utils.omitRow(omissions())); - }) - .then((user) => { - if (auth) { - return authModel - .query() - .insert({ - user_id: user.id, - type: auth.type, - secret: auth.secret, - meta: {}, - }) - .then(() => { - return user; - }); - } - return user; - }) - .then((user) => { - // Create permissions row as well - const is_admin = data.roles.indexOf("admin") !== -1; + await access.can("users:create", data); + data.avatar = gravatar.url(data.email, { default: "mm" }); - return userPermissionModel - .query() - .insert({ - user_id: user.id, - visibility: is_admin ? "all" : "user", - proxy_hosts: "manage", - redirection_hosts: "manage", - dead_hosts: "manage", - streams: "manage", - access_lists: "manage", - certificates: "manage", - }) - .then(() => { - return internalUser.get(access, { id: user.id, expand: ["permissions"] }); - }); - }) - .then((user) => { - // Add to audit log - return internalAuditLog - .add(access, { - action: "created", - object_type: "user", - object_id: user.id, - meta: user, - }) - .then(() => { - return user; - }); + let user = await userModel.query().insertAndFetch(data).then(utils.omitRow(omissions())); + if (auth) { + user = await authModel.query().insert({ + user_id: user.id, + type: auth.type, + secret: auth.secret, + meta: {}, }); + } + + // Create permissions row as well + const isAdmin = data.roles.indexOf("admin") !== -1; + + await userPermissionModel.query().insert({ + user_id: user.id, + visibility: isAdmin ? "all" : "user", + proxy_hosts: "manage", + redirection_hosts: "manage", + dead_hosts: "manage", + streams: "manage", + access_lists: "manage", + certificates: "manage", + }); + + user = await internalUser.get(access, { id: user.id, expand: ["permissions"] }); + + await internalAuditLog.add(access, { + action: "created", + object_type: "user", + object_id: user.id, + meta: user, + }); + + return user; }, /** @@ -316,11 +301,7 @@ const internalUser = { // Query is used for searching if (typeof search_query === "string") { query.where(function () { - this.where("name", "like", `%${search_query}%`).orWhere( - "email", - "like", - `%${search_query}%`, - ); + this.where("name", "like", `%${search_query}%`).orWhere("email", "like", `%${search_query}%`); }); } diff --git a/backend/lib/access.js b/backend/lib/access.js index 20d9e04c..783c416f 100644 --- a/backend/lib/access.js +++ b/backend/lib/access.js @@ -22,13 +22,13 @@ import errs from "./error.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -export default function (token_string) { +export default function (tokenString) { const Token = TokenModel(); - let token_data = null; + let tokenData = null; let initialised = false; - const object_cache = {}; - let allow_internal_access = false; - let user_roles = []; + const objectCache = {}; + let allowInternalAccess = false; + let userRoles = []; let permissions = {}; /** @@ -36,65 +36,58 @@ export default function (token_string) { * * @returns {Promise} */ - this.init = () => { - return new Promise((resolve, reject) => { - if (initialised) { - resolve(); - } else if (!token_string) { - reject(new errs.PermissionError("Permission Denied")); + this.init = async () => { + if (initialised) { + return; + } + + if (!tokenString) { + throw new errs.PermissionError("Permission Denied"); + } + + tokenData = await Token.load(tokenString); + + // At this point we need to load the user from the DB and make sure they: + // - exist (and not soft deleted) + // - still have the appropriate scopes for this token + // This is only required when the User ID is supplied or if the token scope has `user` + if ( + tokenData.attrs.id || + (typeof tokenData.scope !== "undefined" && _.indexOf(tokenData.scope, "user") !== -1) + ) { + // Has token user id or token user scope + const user = await userModel + .query() + .where("id", tokenData.attrs.id) + .andWhere("is_deleted", 0) + .andWhere("is_disabled", 0) + .allowGraph("[permissions]") + .withGraphFetched("[permissions]") + .first(); + + if (user) { + // make sure user has all scopes of the token + // The `user` role is not added against the user row, so we have to just add it here to get past this check. + user.roles.push("user"); + + let ok = true; + _.forEach(tokenData.scope, (scope_item) => { + if (_.indexOf(user.roles, scope_item) === -1) { + ok = false; + } + }); + + if (!ok) { + throw new errs.AuthError("Invalid token scope for User"); + } + initialised = true; + userRoles = user.roles; + permissions = user.permissions; } else { - resolve( - Token.load(token_string).then((data) => { - token_data = data; - - // At this point we need to load the user from the DB and make sure they: - // - exist (and not soft deleted) - // - still have the appropriate scopes for this token - // This is only required when the User ID is supplied or if the token scope has `user` - - if ( - token_data.attrs.id || - (typeof token_data.scope !== "undefined" && - _.indexOf(token_data.scope, "user") !== -1) - ) { - // Has token user id or token user scope - return userModel - .query() - .where("id", token_data.attrs.id) - .andWhere("is_deleted", 0) - .andWhere("is_disabled", 0) - .allowGraph("[permissions]") - .withGraphFetched("[permissions]") - .first() - .then((user) => { - if (user) { - // make sure user has all scopes of the token - // The `user` role is not added against the user row, so we have to just add it here to get past this check. - user.roles.push("user"); - - let is_ok = true; - _.forEach(token_data.scope, (scope_item) => { - if (_.indexOf(user.roles, scope_item) === -1) { - is_ok = false; - } - }); - - if (!is_ok) { - throw new errs.AuthError("Invalid token scope for User"); - } - initialised = true; - user_roles = user.roles; - permissions = user.permissions; - } else { - throw new errs.AuthError("User cannot be loaded for Token"); - } - }); - } - initialised = true; - }), - ); + throw new errs.AuthError("User cannot be loaded for Token"); } - }); + } + initialised = true; }; /** @@ -102,82 +95,64 @@ export default function (token_string) { * This only applies to USER token scopes, as all other tokens are not really bound * by object scopes * - * @param {String} object_type + * @param {String} objectType * @returns {Promise} */ - this.loadObjects = (object_type) => { - return new Promise((resolve, reject) => { - if (Token.hasScope("user")) { - if ( - typeof token_data.attrs.id === "undefined" || - !token_data.attrs.id - ) { - reject(new errs.AuthError("User Token supplied without a User ID")); - } else { - const token_user_id = token_data.attrs.id ? token_data.attrs.id : 0; - let query; + this.loadObjects = async (objectType) => { + let objects = null; - if (typeof object_cache[object_type] === "undefined") { - switch (object_type) { - // USERS - should only return yourself - case "users": - resolve(token_user_id ? [token_user_id] : []); - break; + if (Token.hasScope("user")) { + if (typeof tokenData.attrs.id === "undefined" || !tokenData.attrs.id) { + throw new errs.AuthError("User Token supplied without a User ID"); + } - // Proxy Hosts - case "proxy_hosts": - query = proxyHostModel - .query() - .select("id") - .andWhere("is_deleted", 0); + const tokenUserId = tokenData.attrs.id ? tokenData.attrs.id : 0; + let query; - if (permissions.visibility === "user") { - query.andWhere("owner_user_id", token_user_id); - } + if (typeof objectCache[objectType] !== "undefined") { + objects = objectCache[objectType]; + } else { + switch (objectType) { + // USERS - should only return yourself + case "users": + objects = tokenUserId ? [tokenUserId] : []; + break; - resolve( - query.then((rows) => { - const result = []; - _.forEach(rows, (rule_row) => { - result.push(rule_row.id); - }); - - // enum should not have less than 1 item - if (!result.length) { - result.push(0); - } - - return result; - }), - ); - break; - - // DEFAULT: null - default: - resolve(null); - break; + // Proxy Hosts + case "proxy_hosts": { + query = proxyHostModel.query().select("id").andWhere("is_deleted", 0); + if (permissions.visibility === "user") { + query.andWhere("owner_user_id", tokenUserId); } - } else { - resolve(object_cache[object_type]); + + const rows = await query(); + objects = []; + _.forEach(rows, (ruleRow) => { + result.push(ruleRow.id); + }); + + // enum should not have less than 1 item + if (!objects.length) { + objects.push(0); + } + break; } } - } else { - resolve(null); + objectCache[objectType] = objects; } - }).then((objects) => { - object_cache[object_type] = objects; - return objects; - }); + } + + return objects; }; /** * Creates a schema object on the fly with the IDs and other values required to be checked against the permissionSchema * - * @param {String} permission_label + * @param {String} permissionLabel * @returns {Object} */ - this.getObjectSchema = (permission_label) => { - const base_object_type = permission_label.split(":").shift(); + this.getObjectSchema = async (permissionLabel) => { + const baseObjectType = permissionLabel.split(":").shift(); const schema = { $id: "objects", @@ -200,41 +175,39 @@ export default function (token_string) { }, }; - return this.loadObjects(base_object_type).then((object_result) => { - if (typeof object_result === "object" && object_result !== null) { - schema.properties[base_object_type] = { - type: "number", - enum: object_result, - minimum: 1, - }; - } else { - schema.properties[base_object_type] = { - type: "number", - minimum: 1, - }; - } + const result = await this.loadObjects(baseObjectType); + if (typeof result === "object" && result !== null) { + schema.properties[baseObjectType] = { + type: "number", + enum: result, + minimum: 1, + }; + } else { + schema.properties[baseObjectType] = { + type: "number", + minimum: 1, + }; + } - return schema; - }); + return schema; }; + // here: + return { token: Token, /** * - * @param {Boolean} [allow_internal] + * @param {Boolean} [allowInternal] * @returns {Promise} */ - load: (allow_internal) => { - return new Promise((resolve /*, reject*/) => { - if (token_string) { - resolve(Token.load(token_string)); - } else { - allow_internal_access = allow_internal; - resolve(allow_internal_access || null); - } - }); + load: async (allowInternal) => { + if (tokenString) { + return await Token.load(tokenString); + } + allowInternalAccess = allowInternal; + return allowInternal || null; }, reloadObjects: this.loadObjects, @@ -246,7 +219,7 @@ export default function (token_string) { * @returns {Promise} */ can: async (permission, data) => { - if (allow_internal_access === true) { + if (allowInternalAccess === true) { return true; } @@ -258,7 +231,7 @@ export default function (token_string) { [permission]: { data: data, scope: Token.get("scope"), - roles: user_roles, + roles: userRoles, permission_visibility: permissions.visibility, permission_proxy_hosts: permissions.proxy_hosts, permission_redirection_hosts: permissions.redirection_hosts, @@ -277,10 +250,9 @@ export default function (token_string) { properties: {}, }; - const rawData = fs.readFileSync( - `${__dirname}/access/${permission.replace(/:/gim, "-")}.json`, - { encoding: "utf8" }, - ); + const rawData = fs.readFileSync(`${__dirname}/access/${permission.replace(/:/gim, "-")}.json`, { + encoding: "utf8", + }); permissionSchema.properties[permission] = JSON.parse(rawData); const ajv = new Ajv({ diff --git a/backend/lib/express/jwt-decode.js b/backend/lib/express/jwt-decode.js index 55b7137b..90fe241e 100644 --- a/backend/lib/express/jwt-decode.js +++ b/backend/lib/express/jwt-decode.js @@ -1,15 +1,15 @@ import Access from "../access.js"; export default () => { - return (_, res, next) => { - res.locals.access = null; - const access = new Access(res.locals.token || null); - access - .load() - .then(() => { - res.locals.access = access; - next(); - }) - .catch(next); + return async (_, res, next) => { + try { + res.locals.access = null; + const access = new Access(res.locals.token || null); + await access.load(); + res.locals.access = access; + next(); + } catch (err) { + next(err); + } }; }; diff --git a/backend/lib/validator/api.js b/backend/lib/validator/api.js index 26bfca07..9fd74a43 100644 --- a/backend/lib/validator/api.js +++ b/backend/lib/validator/api.js @@ -14,30 +14,27 @@ const ajv = new Ajv({ * @param {Object} payload * @returns {Promise} */ -function apiValidator(schema, payload /*, description*/) { - return new Promise(function Promise_apiValidator(resolve, reject) { - if (schema === null) { - reject(new errs.ValidationError("Schema is undefined")); - return; - } +const apiValidator = async (schema, payload /*, description*/) => { + if (!schema) { + throw new errs.ValidationError("Schema is undefined"); + } - if (typeof payload === "undefined") { - reject(new errs.ValidationError("Payload is undefined")); - return; - } + // Can't use falsy check here as valid payload could be `0` or `false` + if (typeof payload === "undefined") { + throw new errs.ValidationError("Payload is undefined"); + } - const validate = ajv.compile(schema); - const valid = validate(payload); + const validate = ajv.compile(schema); + const valid = validate(payload); - if (valid && !validate.errors) { - resolve(payload); - } else { - const message = ajv.errorsText(validate.errors); - const err = new errs.ValidationError(message); - err.debug = [validate.errors, payload]; - reject(err); - } - }); -} + if (valid && !validate.errors) { + return payload; + } + + const message = ajv.errorsText(validate.errors); + const err = new errs.ValidationError(message); + err.debug = [validate.errors, payload]; + throw err; +}; export default apiValidator; diff --git a/backend/package.json b/backend/package.json index c5948504..88ab372d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -38,7 +38,7 @@ }, "devDependencies": { "@apidevtools/swagger-parser": "^10.1.0", - "@biomejs/biome": "2.2.0", + "@biomejs/biome": "^2.2.3", "chalk": "4.1.2", "nodemon": "^2.0.2" }, diff --git a/backend/routes/main.js b/backend/routes/main.js index b131fd99..7bc4323d 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -1,6 +1,7 @@ import express from "express"; import errs from "../lib/error.js"; import pjson from "../package.json" with { type: "json" }; +import { isSetup } from "../setup.js"; import auditLogRoutes from "./audit-log.js"; import accessListsRoutes from "./nginx/access_lists.js"; import certificatesHostsRoutes from "./nginx/certificates.js"; @@ -24,11 +25,13 @@ const router = express.Router({ * Health Check * GET /api */ -router.get("/", (_, res /*, next*/) => { +router.get("/", async (_, res /*, next*/) => { const version = pjson.version.split("-").shift().split("."); + const setup = await isSetup(); res.status(200).send({ status: "OK", + setup, version: { major: Number.parseInt(version.shift(), 10), minor: Number.parseInt(version.shift(), 10), diff --git a/backend/routes/tokens.js b/backend/routes/tokens.js index fd563719..76e0dac0 100644 --- a/backend/routes/tokens.js +++ b/backend/routes/tokens.js @@ -2,6 +2,7 @@ import express from "express"; import internalToken from "../internal/token.js"; import jwtdecode from "../lib/express/jwt-decode.js"; import apiValidator from "../lib/validator/api.js"; +import { express as logger } from "../logger.js"; import { getValidationSchema } from "../schema/index.js"; const router = express.Router({ @@ -23,16 +24,17 @@ router * We also piggy back on to this method, allowing admins to get tokens * for services like Job board and Worker. */ - .get(jwtdecode(), (req, res, next) => { - internalToken - .getFreshToken(res.locals.access, { + .get(jwtdecode(), async (req, res, next) => { + try { + const data = await internalToken.getFreshToken(res.locals.access, { expiry: typeof req.query.expiry !== "undefined" ? req.query.expiry : null, scope: typeof req.query.scope !== "undefined" ? req.query.scope : null, - }) - .then((data) => { - res.status(200).send(data); - }) - .catch(next); + }); + res.status(200).send(data); + } catch (err) { + logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } }) /** @@ -41,12 +43,14 @@ router * Create a new Token */ .post(async (req, res, next) => { - apiValidator(getValidationSchema("/tokens", "post"), req.body) - .then(internalToken.getTokenFromEmail) - .then((data) => { - res.status(200).send(data); - }) - .catch(next); + try { + const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body); + const result = await internalToken.getTokenFromEmail(data); + res.status(200).send(result); + } catch (err) { + logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } }); export default router; diff --git a/backend/routes/users.js b/backend/routes/users.js index 9263bb66..ef0b3120 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,10 +1,13 @@ import express from "express"; import internalUser from "../internal/user.js"; +import Access from "../lib/access.js"; import jwtdecode from "../lib/express/jwt-decode.js"; import userIdFromMe from "../lib/express/user-id-from-me.js"; import apiValidator from "../lib/validator/api.js"; import validator from "../lib/validator/index.js"; +import { express as logger } from "../logger.js"; import { getValidationSchema } from "../schema/index.js"; +import { isSetup } from "../setup.js"; const router = express.Router({ caseSensitive: true, @@ -27,35 +30,31 @@ router * * Retrieve all users */ - .get((req, res, next) => { - validator( - { - additionalProperties: false, - properties: { - expand: { - $ref: "common#/properties/expand", - }, - query: { - $ref: "common#/properties/query", + .get(async (req, res, next) => { + try { + const data = await validator( + { + additionalProperties: false, + properties: { + expand: { + $ref: "common#/properties/expand", + }, + query: { + $ref: "common#/properties/query", + }, }, }, - }, - { - expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, - query: typeof req.query.query === "string" ? req.query.query : null, - }, - ) - .then((data) => { - return internalUser.getAll(res.locals.access, data.expand, data.query); - }) - .then((users) => { - res.status(200).send(users); - }) - .catch((err) => { - console.log(err); - next(err); - }); - //.catch(next); + { + expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, + query: typeof req.query.query === "string" ? req.query.query : null, + }, + ); + const users = await internalUser.getAll(res.locals.access, data.expand, data.query); + res.status(200).send(users); + } catch (err) { + logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } }) /** @@ -63,15 +62,36 @@ router * * Create a new User */ - .post((req, res, next) => { - apiValidator(getValidationSchema("/users", "post"), req.body) - .then((payload) => { - return internalUser.create(res.locals.access, payload); - }) - .then((result) => { - res.status(201).send(result); - }) - .catch(next); + .post(async (req, res, next) => { + const body = req.body; + + try { + // If we are in setup mode, we don't check access for current user + const setup = await isSetup(); + if (!setup) { + logger.info("Creating a new user in setup mode"); + const access = new Access(null); + await access.load(true); + res.locals.access = access; + + // We are in setup mode, set some defaults for this first new user, such as making + // them an admin. + body.is_disabled = false; + if (typeof body.roles !== "object" || body.roles === null) { + body.roles = []; + } + if (body.roles.indexOf("admin") === -1) { + body.roles.push("admin"); + } + } + + const payload = await apiValidator(getValidationSchema("/users", "post"), body); + const user = await internalUser.create(res.locals.access, payload); + res.status(201).send(user); + } catch (err) { + logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } }); /** @@ -92,39 +112,37 @@ router * * Retrieve a specific user */ - .get((req, res, next) => { - validator( - { - required: ["user_id"], - additionalProperties: false, - properties: { - user_id: { - $ref: "common#/properties/id", - }, - expand: { - $ref: "common#/properties/expand", + .get(async (req, res, next) => { + try { + const data = await validator( + { + required: ["user_id"], + additionalProperties: false, + properties: { + user_id: { + $ref: "common#/properties/id", + }, + expand: { + $ref: "common#/properties/expand", + }, }, }, - }, - { - user_id: req.params.user_id, - expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, - }, - ) - .then((data) => { - return internalUser.get(res.locals.access, { - id: data.user_id, - expand: data.expand, - omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id), - }); - }) - .then((user) => { - res.status(200).send(user); - }) - .catch((err) => { - console.log(err); - next(err); + { + user_id: req.params.user_id, + expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, + }, + ); + + const user = await internalUser.get(res.locals.access, { + id: data.user_id, + expand: data.expand, + omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id), }); + res.status(200).send(user); + } catch (err) { + logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } }) /** @@ -132,16 +150,16 @@ router * * Update and existing user */ - .put((req, res, next) => { - apiValidator(getValidationSchema("/users/{userID}", "put"), req.body) - .then((payload) => { - payload.id = req.params.user_id; - return internalUser.update(res.locals.access, payload); - }) - .then((result) => { - res.status(200).send(result); - }) - .catch(next); + .put(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/users/{userID}", "put"), req.body); + payload.id = req.params.user_id; + const result = await internalUser.update(res.locals.access, payload); + res.status(200).send(result); + } catch (err) { + logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } }) /** @@ -149,13 +167,14 @@ router * * Update and existing user */ - .delete((req, res, next) => { - internalUser - .delete(res.locals.access, { id: req.params.user_id }) - .then((result) => { - res.status(200).send(result); - }) - .catch(next); + .delete(async (req, res, next) => { + try { + const result = await internalUser.delete(res.locals.access, { id: req.params.user_id }); + res.status(200).send(result); + } catch (err) { + logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } }); /** @@ -176,16 +195,16 @@ router * * Update password for a user */ - .put((req, res, next) => { - apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body) - .then((payload) => { - payload.id = req.params.user_id; - return internalUser.setPassword(res.locals.access, payload); - }) - .then((result) => { - res.status(200).send(result); - }) - .catch(next); + .put(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body); + payload.id = req.params.user_id; + const result = await internalUser.setPassword(res.locals.access, payload); + res.status(200).send(result); + } catch (err) { + logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } }); /** @@ -206,16 +225,16 @@ router * * Set some or all permissions for a user */ - .put((req, res, next) => { - apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body) - .then((payload) => { - payload.id = req.params.user_id; - return internalUser.setPermissions(res.locals.access, payload); - }) - .then((result) => { - res.status(200).send(result); - }) - .catch(next); + .put(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body); + payload.id = req.params.user_id; + const result = await internalUser.setPermissions(res.locals.access, payload); + res.status(200).send(result); + } catch (err) { + logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } }); /** @@ -235,13 +254,16 @@ router * * Log in as a user */ - .post((req, res, next) => { - internalUser - .loginAs(res.locals.access, { id: Number.parseInt(req.params.user_id, 10) }) - .then((result) => { - res.status(200).send(result); - }) - .catch(next); + .post(async (req, res, next) => { + try { + const result = await internalUser.loginAs(res.locals.access, { + id: Number.parseInt(req.params.user_id, 10), + }); + res.status(200).send(result); + } catch (err) { + logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } }); export default router; diff --git a/backend/setup.js b/backend/setup.js index 4307c6fc..4a9d5e05 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -7,65 +7,68 @@ import settingModel from "./models/setting.js"; import userModel from "./models/user.js"; import userPermissionModel from "./models/user_permission.js"; +export const isSetup = async () => { + const row = await userModel.query().select("id").where("is_deleted", 0).first(); + return row?.id > 0; +} + /** * Creates a default admin users if one doesn't already exist in the database * * @returns {Promise} */ -const setupDefaultUser = () => { - return userModel - .query() - .select("id") - .where("is_deleted", 0) - .first() - .then((row) => { - if (!row || !row.id) { - // Create a new user and set password - const email = (process.env.INITIAL_ADMIN_EMAIL || 'admin@example.com').toLowerCase(); - const password = process.env.INITIAL_ADMIN_PASSWORD || "changeme"; +const setupDefaultUser = async () => { + const initialAdminEmail = process.env.INITIAL_ADMIN_EMAIL; + const initialAdminPassword = process.env.INITIAL_ADMIN_PASSWORD; - logger.info(`Creating a new user: ${email} with password: ${password}`); + // This will only create a new user when there are no active users in the database + // and the INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD environment variables are set. + // Otherwise, users should be shown the setup wizard in the frontend. + // I'm keeping this legacy behavior in case some people are automating deployments. - const data = { - is_deleted: 0, - email: email, - name: "Administrator", - nickname: "Admin", - avatar: "", - roles: ["admin"], - }; + if (!initialAdminEmail || !initialAdminPassword) { + return Promise.resolve(); + } - return userModel - .query() - .insertAndFetch(data) - .then((user) => { - return authModel - .query() - .insert({ - user_id: user.id, - type: "password", - secret: password, - meta: {}, - }) - .then(() => { - return userPermissionModel.query().insert({ - user_id: user.id, - visibility: "all", - proxy_hosts: "manage", - redirection_hosts: "manage", - dead_hosts: "manage", - streams: "manage", - access_lists: "manage", - certificates: "manage", - }); - }); - }) - .then(() => { - logger.info("Initial admin setup completed"); - }); - } - logger.debug("Admin user setup not required"); + const userIsetup = await isSetup(); + if (!userIsetup) { + // Create a new user and set password + logger.info(`Creating a new user: ${initialAdminEmail} with password: ${initialAdminPassword}`); + + const data = { + is_deleted: 0, + email: email, + name: "Administrator", + nickname: "Admin", + avatar: "", + roles: ["admin"], + }; + + const user = await userModel + .query() + .insertAndFetch(data); + + await authModel + .query() + .insert({ + user_id: user.id, + type: "password", + secret: password, + meta: {}, + }); + + await userPermissionModel.query().insert({ + user_id: user.id, + visibility: "all", + proxy_hosts: "manage", + redirection_hosts: "manage", + dead_hosts: "manage", + streams: "manage", + access_lists: "manage", + certificates: "manage", }); + logger.info("Initial admin setup completed"); + } }; /** @@ -73,29 +76,25 @@ const setupDefaultUser = () => { * * @returns {Promise} */ -const setupDefaultSettings = () => { - return settingModel +const setupDefaultSettings = async () => { + const row = await settingModel .query() .select("id") .where({ id: "default-site" }) - .first() - .then((row) => { - if (!row || !row.id) { - settingModel - .query() - .insert({ - id: "default-site", - name: "Default Site", - description: "What to show when Nginx is hit with an unknown Host", - value: "congratulations", - meta: {}, - }) - .then(() => { - logger.info("Default settings added"); - }); - } - logger.debug("Default setting setup not required"); - }); + .first(); + + if (!row?.id) { + await settingModel + .query() + .insert({ + id: "default-site", + name: "Default Site", + description: "What to show when Nginx is hit with an unknown Host", + value: "congratulations", + meta: {}, + }); + logger.info("Default settings added"); + } }; /** @@ -103,43 +102,41 @@ const setupDefaultSettings = () => { * * @returns {Promise} */ -const setupCertbotPlugins = () => { - return certificateModel +const setupCertbotPlugins = async () => { + const certificates = await certificateModel .query() .where("is_deleted", 0) - .andWhere("provider", "letsencrypt") - .then((certificates) => { - if (certificates?.length) { - const plugins = []; - const promises = []; + .andWhere("provider", "letsencrypt"); - certificates.map((certificate) => { - if (certificate.meta && certificate.meta.dns_challenge === true) { - if (plugins.indexOf(certificate.meta.dns_provider) === -1) { - plugins.push(certificate.meta.dns_provider); - } + if (certificates?.length) { + const plugins = []; + const promises = []; - // Make sure credentials file exists - const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; - // Escape single quotes and backslashes - const escapedCredentials = certificate.meta.dns_provider_credentials - .replaceAll("'", "\\'") - .replaceAll("\\", "\\\\"); - const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`; - promises.push(utils.exec(credentials_cmd)); - } - return true; - }); + certificates.map((certificate) => { + if (certificate.meta && certificate.meta.dns_challenge === true) { + if (plugins.indexOf(certificate.meta.dns_provider) === -1) { + plugins.push(certificate.meta.dns_provider); + } - return installPlugins(plugins).then(() => { - if (promises.length) { - return Promise.all(promises).then(() => { - logger.info(`Added Certbot plugins ${plugins.join(", ")}`); - }); - } - }); + // Make sure credentials file exists + const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; + // Escape single quotes and backslashes + const escapedCredentials = certificate.meta.dns_provider_credentials + .replaceAll("'", "\\'") + .replaceAll("\\", "\\\\"); + const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`; + promises.push(utils.exec(credentials_cmd)); } + return true; }); + + await installPlugins(plugins); + + if (promises.length) { + await Promise.all(promises); + logger.info(`Added Certbot plugins ${plugins.join(", ")}`); + } + } }; /** diff --git a/backend/yarn.lock b/backend/yarn.lock index fd936a9b..5c414a27 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -43,59 +43,59 @@ ajv-draft-04 "^1.0.0" call-me-maybe "^1.0.2" -"@biomejs/biome@2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.2.0.tgz#823ba77363651f310c47909747c879791ebd15c9" - integrity sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw== +"@biomejs/biome@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.2.3.tgz#9d17991c80e006c5ca3e21bebe84b7afd71559e3" + integrity sha512-9w0uMTvPrIdvUrxazZ42Ib7t8Y2yoGLKLdNne93RLICmaHw7mcLv4PPb5LvZLJF3141gQHiCColOh/v6VWlWmg== optionalDependencies: - "@biomejs/cli-darwin-arm64" "2.2.0" - "@biomejs/cli-darwin-x64" "2.2.0" - "@biomejs/cli-linux-arm64" "2.2.0" - "@biomejs/cli-linux-arm64-musl" "2.2.0" - "@biomejs/cli-linux-x64" "2.2.0" - "@biomejs/cli-linux-x64-musl" "2.2.0" - "@biomejs/cli-win32-arm64" "2.2.0" - "@biomejs/cli-win32-x64" "2.2.0" + "@biomejs/cli-darwin-arm64" "2.2.3" + "@biomejs/cli-darwin-x64" "2.2.3" + "@biomejs/cli-linux-arm64" "2.2.3" + "@biomejs/cli-linux-arm64-musl" "2.2.3" + "@biomejs/cli-linux-x64" "2.2.3" + "@biomejs/cli-linux-x64-musl" "2.2.3" + "@biomejs/cli-win32-arm64" "2.2.3" + "@biomejs/cli-win32-x64" "2.2.3" -"@biomejs/cli-darwin-arm64@2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.0.tgz#1abf9508e7d0776871710687ddad36e692dce3bc" - integrity sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg== +"@biomejs/cli-darwin-arm64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.3.tgz#e18240343fa705dafb08ba72a7b0e88f04a8be3e" + integrity sha512-OrqQVBpadB5eqzinXN4+Q6honBz+tTlKVCsbEuEpljK8ASSItzIRZUA02mTikl3H/1nO2BMPFiJ0nkEZNy3B1w== -"@biomejs/cli-darwin-x64@2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.0.tgz#3a51aa569505fedd3a32bb914d608ec27d87f26d" - integrity sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw== +"@biomejs/cli-darwin-x64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.3.tgz#964b51c9f649e3a725f6f43e75c4173b9ab8a3ae" + integrity sha512-OCdBpb1TmyfsTgBAM1kPMXyYKTohQ48WpiN9tkt9xvU6gKVKHY4oVwteBebiOqyfyzCNaSiuKIPjmHjUZ2ZNMg== -"@biomejs/cli-linux-arm64-musl@2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.0.tgz#4d720930732a825b7a8c7cfe1741aec9e7d5ae1d" - integrity sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ== +"@biomejs/cli-linux-arm64-musl@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.3.tgz#1756c37960d5585ca865e184539b113e48719b41" + integrity sha512-q3w9jJ6JFPZPeqyvwwPeaiS/6NEszZ+pXKF+IczNo8Xj6fsii45a4gEEicKyKIytalV+s829ACZujQlXAiVLBQ== -"@biomejs/cli-linux-arm64@2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.0.tgz#d0a5c153ff9243b15600781947d70d6038226feb" - integrity sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw== +"@biomejs/cli-linux-arm64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.3.tgz#036c6334d5b09b51233ce5120b18f4c89a15a74c" + integrity sha512-g/Uta2DqYpECxG+vUmTAmUKlVhnGEcY7DXWgKP8ruLRa8Si1QHsWknPY3B/wCo0KgYiFIOAZ9hjsHfNb9L85+g== -"@biomejs/cli-linux-x64-musl@2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.0.tgz#946095b0a444f395b2df9244153e1cd6b07404c0" - integrity sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg== +"@biomejs/cli-linux-x64-musl@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.3.tgz#e6cce01910b9f56c1645c5518595d0b1eb38c245" + integrity sha512-y76Dn4vkP1sMRGPFlNc+OTETBhGPJ90jY3il6jAfur8XWrYBQV3swZ1Jo0R2g+JpOeeoA0cOwM7mJG6svDz79w== -"@biomejs/cli-linux-x64@2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.0.tgz#ae01e0a70c7cd9f842c77dfb4ebd425734667a34" - integrity sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw== +"@biomejs/cli-linux-x64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.3.tgz#f328e7cfde92fad6c7ad215df1f51b146b4ed007" + integrity sha512-LEtyYL1fJsvw35CxrbQ0gZoxOG3oZsAjzfRdvRBRHxOpQ91Q5doRVjvWW/wepgSdgk5hlaNzfeqpyGmfSD0Eyw== -"@biomejs/cli-win32-arm64@2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.0.tgz#09a3988b9d4bab8b8b3a41b4de9560bf70943964" - integrity sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA== +"@biomejs/cli-win32-arm64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.3.tgz#b8d64ca6dc1c50b8f3d42475afd31b7b44460935" + integrity sha512-Ms9zFYzjcJK7LV+AOMYnjN3pV3xL8Prxf9aWdDVL74onLn5kcvZ1ZMQswE5XHtnd/r/0bnUd928Rpbs14BzVmA== -"@biomejs/cli-win32-x64@2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.0.tgz#5d2523b421d847b13fac146cf745436ea8a72b95" - integrity sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww== +"@biomejs/cli-win32-x64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.3.tgz#ecafffddf0c0675c825735c7cc917cbc8c538433" + integrity sha512-gvCpewE7mBwBIpqk1YrUqNR4mCiyJm6UI3YWQQXkedSSEwzRdodRpaKhbdbHw1/hmTWOVXQ+Eih5Qctf4TCVOQ== "@gar/promisify@^1.0.1": version "1.1.3" diff --git a/docker/docker-compose.ci.yml b/docker/docker-compose.ci.yml index 280a0546..e656c926 100644 --- a/docker/docker-compose.ci.yml +++ b/docker/docker-compose.ci.yml @@ -7,6 +7,7 @@ services: fullstack: image: "${IMAGE}:${BRANCH_LOWER}-ci-${BUILD_NUMBER}" environment: + TZ: "${TZ:-Australia/Brisbane}" DEBUG: 'true' FORCE_COLOR: 1 # Required for DNS Certificate provisioning in CI diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5abe057b..0de9238b 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -18,6 +18,7 @@ services: - website2.example.com - website3.example.com environment: + TZ: "${TZ:-Australia/Brisbane}" PUID: 1000 PGID: 1000 FORCE_COLOR: 1 @@ -49,6 +50,7 @@ services: - ../backend:/app - ../frontend:/app/frontend - ../global:/app/global + - '/etc/localtime:/etc/localtime:ro' healthcheck: test: ["CMD", "/usr/bin/check-health"] interval: 10s diff --git a/docs/package.json b/docs/package.json index 3e3dcba2..d4523250 100644 --- a/docs/package.json +++ b/docs/package.json @@ -5,7 +5,7 @@ "preview": "vitepress preview" }, "devDependencies": { - "vitepress": "^1.4.0" + "vitepress": "^1.6.4" }, "dependencies": {} } diff --git a/docs/src/advanced-config/index.md b/docs/src/advanced-config/index.md index 4a7c260e..e4a9594e 100644 --- a/docs/src/advanced-config/index.md +++ b/docs/src/advanced-config/index.md @@ -228,3 +228,13 @@ To enable the geoip2 module, you can create the custom configuration file `/data load_module /usr/lib/nginx/modules/ngx_http_geoip2_module.so; load_module /usr/lib/nginx/modules/ngx_stream_geoip2_module.so; ``` + +## Auto Initial User Creation + +Setting these environment variables will create the default user on startup, skipping the UI first user setup screen: + +``` + environment: + INITIAL_ADMIN_EMAIL: my@example.com + INITIAL_ADMIN_PASSWORD: mypassword1 +``` diff --git a/docs/src/faq/index.md b/docs/src/faq/index.md index ea375f47..b3668562 100644 --- a/docs/src/faq/index.md +++ b/docs/src/faq/index.md @@ -23,4 +23,10 @@ Your best bet is to ask the [Reddit community for support](https://www.reddit.co ## When adding username and password access control to a proxy host, I can no longer login into the app. -Having an Access Control List (ACL) with username and password requires the browser to always send this username and password in the `Authorization` header on each request. If your proxied app also requires authentication (like Nginx Proxy Manager itself), most likely the app will also use the `Authorization` header to transmit this information, as this is the standardized header meant for this kind of information. However having multiples of the same headers is not allowed in the [internet standard](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) and almost all apps do not support multiple values in the `Authorization` header. Hence one of the two logins will be broken. This can only be fixed by either removing one of the logins or by changing the app to use other non-standard headers for authorization. +Having an Access Control List (ACL) with username and password requires the browser to always send this username +and password in the `Authorization` header on each request. If your proxied app also requires authentication (like +Nginx Proxy Manager itself), most likely the app will also use the `Authorization` header to transmit this information, +as this is the standardized header meant for this kind of information. However having multiples of the same headers +is not allowed in the [internet standard](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) and almost all apps +do not support multiple values in the `Authorization` header. Hence one of the two logins will be broken. This can +only be fixed by either removing one of the logins or by changing the app to use other non-standard headers for authorization. diff --git a/docs/src/guide/index.md b/docs/src/guide/index.md index fcf176fa..c07b5f15 100644 --- a/docs/src/guide/index.md +++ b/docs/src/guide/index.md @@ -35,7 +35,7 @@ so that the barrier for entry here is low. ## Features -- Beautiful and Secure Admin Interface based on [Tabler](https://tabler.github.io/) +- Beautiful and Secure Admin Interface based on [Tabler](https://tabler.io/) - Easily create forwarding domains, redirections, streams and 404 hosts without knowing anything about Nginx - Free SSL using Let's Encrypt or provide your own custom SSL certificates - Access Lists and basic HTTP Authentication for your hosts @@ -66,6 +66,8 @@ services: app: image: 'jc21/nginx-proxy-manager:latest' restart: unless-stopped + environment: + TZ: "Australia/Brisbane" ports: - '80:80' - '81:81' @@ -89,17 +91,10 @@ docker compose up -d 4. Log in to the Admin UI When your docker container is running, connect to it on port `81` for the admin interface. -Sometimes this can take a little bit because of the entropy of keys. [http://127.0.0.1:81](http://127.0.0.1:81) -Default Admin User: -``` -Email: admin@example.com -Password: changeme -``` - -Immediately after logging in with this default user you will be asked to modify your details and change your password. +This startup can take a minute depending on your hardware. ## Contributing diff --git a/docs/src/setup/index.md b/docs/src/setup/index.md index c2296da7..d13e5392 100644 --- a/docs/src/setup/index.md +++ b/docs/src/setup/index.md @@ -13,6 +13,7 @@ services: app: image: 'jc21/nginx-proxy-manager:latest' restart: unless-stopped + ports: # These ports are in format : - '80:80' # Public HTTP Port @@ -21,7 +22,9 @@ services: # Add any other Stream port you want to expose # - '21:21' # FTP - #environment: + environment: + TZ: "Australia/Brisbane" + # Uncomment this if you want to change the location of # the SQLite DB file within the container # DB_SQLITE_FILE: "/data/database.sqlite" @@ -65,6 +68,7 @@ services: # Add any other Stream port you want to expose # - '21:21' # FTP environment: + TZ: "Australia/Brisbane" # Mysql/Maria connection parameters: DB_MYSQL_HOST: "db" DB_MYSQL_PORT: 3306 @@ -115,6 +119,7 @@ services: # Add any other Stream port you want to expose # - '21:21' # FTP environment: + TZ: "Australia/Brisbane" # Postgres parameters: DB_POSTGRES_HOST: 'db' DB_POSTGRES_PORT: '5432' @@ -173,21 +178,3 @@ After the app is running for the first time, the following will happen: 3. A default admin user will be created This process can take a couple of minutes depending on your machine. - -## Default Administrator User - -``` -Email: admin@example.com -Password: changeme -``` - -Immediately after logging in with this default user you will be asked to modify your details and change your password. You can change defaults with: - - -``` - environment: - INITIAL_ADMIN_EMAIL: my@example.com - INITIAL_ADMIN_PASSWORD: mypassword1 -``` - - diff --git a/frontend/biome.json b/frontend/biome.json index 1c0d3cef..10877ff3 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.2/schema.json", + "$schema": "https://biomejs.dev/schemas/2.2.3/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/frontend/package.json b/frontend/package.json index c94566ff..1cf06e27 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,7 +34,7 @@ "rooks": "^9.2.0" }, "devDependencies": { - "@biomejs/biome": "2.2.2", + "@biomejs/biome": "^2.2.3", "@formatjs/cli": "^6.7.2", "@tanstack/react-query-devtools": "^5.85.6", "@testing-library/dom": "^10.4.1", diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 054b4400..6aa8f089 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -13,8 +13,9 @@ import { import { useAuthState } from "src/context"; import { useHealth } from "src/hooks"; -const Dashboard = lazy(() => import("src/pages/Dashboard")); +const Setup = lazy(() => import("src/pages/Setup")); const Login = lazy(() => import("src/pages/Login")); +const Dashboard = lazy(() => import("src/pages/Dashboard")); const Settings = lazy(() => import("src/pages/Settings")); const Certificates = lazy(() => import("src/pages/Certificates")); const Access = lazy(() => import("src/pages/Access")); @@ -37,6 +38,10 @@ function Router() { return ; } + if (!health.data?.setup) { + return ; + } + if (!authenticated) { return ( }> diff --git a/frontend/src/api/backend/base.ts b/frontend/src/api/backend/base.ts index e15cd50e..178f13cb 100644 --- a/frontend/src/api/backend/base.ts +++ b/frontend/src/api/backend/base.ts @@ -88,15 +88,19 @@ interface PostArgs { url: string; params?: queryString.StringifiableRecord; data?: any; + noAuth?: boolean; } -export async function post({ url, params, data }: PostArgs, abortController?: AbortController) { +export async function post({ url, params, data, noAuth }: PostArgs, abortController?: AbortController) { const apiUrl = buildUrl({ url, params }); const method = "POST"; - let headers = { - ...buildAuthHeader(), - }; + let headers: Record = {}; + if (!noAuth) { + headers = { + ...buildAuthHeader(), + }; + } let body: string | FormData | undefined; // Check if the data is an instance of FormData diff --git a/frontend/src/api/backend/createUser.ts b/frontend/src/api/backend/createUser.ts index e6789d7f..1aadda34 100644 --- a/frontend/src/api/backend/createUser.ts +++ b/frontend/src/api/backend/createUser.ts @@ -1,12 +1,27 @@ import * as api from "./base"; import type { User } from "./models"; -export async function createUser(item: User, abortController?: AbortController): Promise { +export interface AuthOptions { + type: string; + secret: string; +} + +export interface NewUser { + name: string; + nickname: string; + email: string; + isDisabled?: boolean; + auth?: AuthOptions; + roles?: string[]; +} + +export async function createUser(item: NewUser, noAuth?: boolean, abortController?: AbortController): Promise { return await api.post( { url: "/users", // todo: only use whitelist of fields for this data data: item, + noAuth, }, abortController, ); diff --git a/frontend/src/api/backend/responseTypes.ts b/frontend/src/api/backend/responseTypes.ts index 6e5e9abb..99e95b3a 100644 --- a/frontend/src/api/backend/responseTypes.ts +++ b/frontend/src/api/backend/responseTypes.ts @@ -3,6 +3,7 @@ import type { AppVersion } from "./models"; export interface HealthResponse { status: string; version: AppVersion; + setup: boolean; } export interface TokenResponse { diff --git a/frontend/src/locale/lang/en.json b/frontend/src/locale/lang/en.json index d71431d1..1373bfc0 100644 --- a/frontend/src/locale/lang/en.json +++ b/frontend/src/locale/lang/en.json @@ -72,6 +72,8 @@ "role.standard-user": "Standard User", "save": "Save", "settings.title": "Settings", + "setup.preamble": "Get started by creating your admin account.", + "setup.title": "Welcome!", "sign-in": "Sign in", "streams.actions-title": "Stream #{id}", "streams.add": "Add Stream", diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index dd20f393..64ebf373 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -218,6 +218,12 @@ "settings.title": { "defaultMessage": "Settings" }, + "setup.preamble": { + "defaultMessage": "Get started by creating your admin account." + }, + "setup.title": { + "defaultMessage": "Welcome!" + }, "sign-in": { "defaultMessage": "Sign in" }, diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx index 78e2072d..452b3205 100644 --- a/frontend/src/pages/Dashboard/index.tsx +++ b/frontend/src/pages/Dashboard/index.tsx @@ -122,18 +122,15 @@ const Dashboard = () => {
 				{`Todo:
 
+- Users: permissions modal and trigger after adding user
 - modal dialgs for everything
 - Tables
 - check mobile
 - fix bad jwt not refreshing entire page
 - add help docs for host types
-- show user as disabled on user table
 
 More for api, then implement here:
 - Properly implement refresh tokens
-- don't create default user, instead use the is_setup from v3
-  - also remove the initial user/pass env vars
-  - update docs for this
 - Add error message_18n for all backend errors
 - minor: certificates expand with hosts needs to omit 'is_deleted'
 `}
diff --git a/frontend/src/pages/Setup/index.module.css b/frontend/src/pages/Setup/index.module.css
new file mode 100644
index 00000000..16f8477c
--- /dev/null
+++ b/frontend/src/pages/Setup/index.module.css
@@ -0,0 +1,10 @@
+.logo {
+	width: 200px;
+}
+
+.helperBtns {
+	position: absolute;
+	top: 10px;
+	right: 10px;
+	z-index: 1000;
+}
diff --git a/frontend/src/pages/Setup/index.tsx b/frontend/src/pages/Setup/index.tsx
new file mode 100644
index 00000000..10d529bc
--- /dev/null
+++ b/frontend/src/pages/Setup/index.tsx
@@ -0,0 +1,191 @@
+import { useQueryClient } from "@tanstack/react-query";
+import cn from "classnames";
+import { Field, Form, Formik } from "formik";
+import { useState } from "react";
+import { Alert } from "react-bootstrap";
+import { createUser } from "src/api/backend";
+import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components";
+import { useAuthState } from "src/context";
+import { intl } from "src/locale";
+import { validateEmail, validateString } from "src/modules/Validations";
+import styles from "./index.module.css";
+
+interface Payload {
+	name: string;
+	email: string;
+	password: string;
+}
+
+export default function Setup() {
+	const queryClient = useQueryClient();
+	const { login } = useAuthState();
+	const [errorMsg, setErrorMsg] = useState(null);
+
+	const onSubmit = async (values: Payload, { setSubmitting }: any) => {
+		setErrorMsg(null);
+
+		// Set a nickname, which is the first word of the name
+		const nickname = values.name.split(" ")[0];
+
+		const { password, ...payload } = {
+			...values,
+			...{
+				nickname,
+				auth: {
+					type: "password",
+					secret: values.password,
+				},
+			},
+		};
+
+		try {
+			const user = await createUser(payload, true);
+			if (user && typeof user.id !== "undefined" && user.id) {
+				try {
+					await login(user.email, password);
+					// Trigger a Health change
+					await queryClient.refetchQueries({ queryKey: ["health"] });
+					// window.location.reload();
+				} catch (err: any) {
+					setErrorMsg(err.message);
+				}
+			} else {
+				setErrorMsg("cannot_create_user");
+			}
+		} catch (err: any) {
+			setErrorMsg(err.message);
+		}
+		setSubmitting(false);
+	};
+
+	return (
+		
+			
+ + +
+
+
+ Nginx Proxy Manager +
+
+ setErrorMsg(null)} dismissible> + {errorMsg} + + + {({ isSubmitting }) => ( +
+
+

{intl.formatMessage({ id: "setup.title" })}

+

{intl.formatMessage({ id: "setup.preamble" })}

+
+
+
+
+ + {({ field, form }: any) => ( +
+ + + {form.errors.name ? ( +
+ {form.errors.name && form.touched.name + ? form.errors.name + : null} +
+ ) : null} +
+ )} +
+
+
+ + {({ field, form }: any) => ( +
+ + + {form.errors.email ? ( +
+ {form.errors.email && form.touched.email + ? form.errors.email + : null} +
+ ) : null} +
+ )} +
+
+
+ + {({ field, form }: any) => ( +
+ + + {form.errors.password ? ( +
+ {form.errors.password && form.touched.password + ? form.errors.password + : null} +
+ ) : null} +
+ )} +
+
+
+
+ +
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index dcad569f..7623b802 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -178,59 +178,59 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" -"@biomejs/biome@2.2.2": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.2.2.tgz#a039a59ce8612ee706c0abbf285eb3ae04a6f1a9" - integrity sha512-j1omAiQWCkhuLgwpMKisNKnsM6W8Xtt1l0WZmqY/dFj8QPNkIoTvk4tSsi40FaAAkBE1PU0AFG2RWFBWenAn+w== +"@biomejs/biome@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.2.3.tgz#9d17991c80e006c5ca3e21bebe84b7afd71559e3" + integrity sha512-9w0uMTvPrIdvUrxazZ42Ib7t8Y2yoGLKLdNne93RLICmaHw7mcLv4PPb5LvZLJF3141gQHiCColOh/v6VWlWmg== optionalDependencies: - "@biomejs/cli-darwin-arm64" "2.2.2" - "@biomejs/cli-darwin-x64" "2.2.2" - "@biomejs/cli-linux-arm64" "2.2.2" - "@biomejs/cli-linux-arm64-musl" "2.2.2" - "@biomejs/cli-linux-x64" "2.2.2" - "@biomejs/cli-linux-x64-musl" "2.2.2" - "@biomejs/cli-win32-arm64" "2.2.2" - "@biomejs/cli-win32-x64" "2.2.2" + "@biomejs/cli-darwin-arm64" "2.2.3" + "@biomejs/cli-darwin-x64" "2.2.3" + "@biomejs/cli-linux-arm64" "2.2.3" + "@biomejs/cli-linux-arm64-musl" "2.2.3" + "@biomejs/cli-linux-x64" "2.2.3" + "@biomejs/cli-linux-x64-musl" "2.2.3" + "@biomejs/cli-win32-arm64" "2.2.3" + "@biomejs/cli-win32-x64" "2.2.3" -"@biomejs/cli-darwin-arm64@2.2.2": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.2.tgz#18560240d374d8fa89df7d5af0f2101971a05d04" - integrity sha512-6ePfbCeCPryWu0CXlzsWNZgVz/kBEvHiPyNpmViSt6A2eoDf4kXs3YnwQPzGjy8oBgQulrHcLnJL0nkCh80mlQ== +"@biomejs/cli-darwin-arm64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.3.tgz#e18240343fa705dafb08ba72a7b0e88f04a8be3e" + integrity sha512-OrqQVBpadB5eqzinXN4+Q6honBz+tTlKVCsbEuEpljK8ASSItzIRZUA02mTikl3H/1nO2BMPFiJ0nkEZNy3B1w== -"@biomejs/cli-darwin-x64@2.2.2": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.2.tgz#68bf6e2dc4384f96d590b2c342bfa09fbb7be492" - integrity sha512-Tn4JmVO+rXsbRslml7FvKaNrlgUeJot++FkvYIhl1OkslVCofAtS35MPlBMhXgKWF9RNr9cwHanrPTUUXcYGag== +"@biomejs/cli-darwin-x64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.3.tgz#964b51c9f649e3a725f6f43e75c4173b9ab8a3ae" + integrity sha512-OCdBpb1TmyfsTgBAM1kPMXyYKTohQ48WpiN9tkt9xvU6gKVKHY4oVwteBebiOqyfyzCNaSiuKIPjmHjUZ2ZNMg== -"@biomejs/cli-linux-arm64-musl@2.2.2": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.2.tgz#3f091595615739c69ccc300a5eb3acbefca3996c" - integrity sha512-/MhYg+Bd6renn6i1ylGFL5snYUn/Ct7zoGVKhxnro3bwekiZYE8Kl39BSb0MeuqM+72sThkQv4TnNubU9njQRw== +"@biomejs/cli-linux-arm64-musl@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.3.tgz#1756c37960d5585ca865e184539b113e48719b41" + integrity sha512-q3w9jJ6JFPZPeqyvwwPeaiS/6NEszZ+pXKF+IczNo8Xj6fsii45a4gEEicKyKIytalV+s829ACZujQlXAiVLBQ== -"@biomejs/cli-linux-arm64@2.2.2": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.2.tgz#9ed17fc01681e83a1d52efd366f9edc3efbca0ae" - integrity sha512-JfrK3gdmWWTh2J5tq/rcWCOsImVyzUnOS2fkjhiYKCQ+v8PqM+du5cfB7G1kXas+7KQeKSWALv18iQqdtIMvzw== +"@biomejs/cli-linux-arm64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.3.tgz#036c6334d5b09b51233ce5120b18f4c89a15a74c" + integrity sha512-g/Uta2DqYpECxG+vUmTAmUKlVhnGEcY7DXWgKP8ruLRa8Si1QHsWknPY3B/wCo0KgYiFIOAZ9hjsHfNb9L85+g== -"@biomejs/cli-linux-x64-musl@2.2.2": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.2.tgz#01bcb119f2f94af5e5610a961b9ffcfa26cf2a3b" - integrity sha512-ZCLXcZvjZKSiRY/cFANKg+z6Fhsf9MHOzj+NrDQcM+LbqYRT97LyCLWy2AS+W2vP+i89RyRM+kbGpUzbRTYWig== +"@biomejs/cli-linux-x64-musl@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.3.tgz#e6cce01910b9f56c1645c5518595d0b1eb38c245" + integrity sha512-y76Dn4vkP1sMRGPFlNc+OTETBhGPJ90jY3il6jAfur8XWrYBQV3swZ1Jo0R2g+JpOeeoA0cOwM7mJG6svDz79w== -"@biomejs/cli-linux-x64@2.2.2": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.2.tgz#c5d0c6ce58b90e30f123e2cfdb29d2add65e2384" - integrity sha512-Ogb+77edO5LEP/xbNicACOWVLt8mgC+E1wmpUakr+O4nKwLt9vXe74YNuT3T1dUBxC/SnrVmlzZFC7kQJEfquQ== +"@biomejs/cli-linux-x64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.3.tgz#f328e7cfde92fad6c7ad215df1f51b146b4ed007" + integrity sha512-LEtyYL1fJsvw35CxrbQ0gZoxOG3oZsAjzfRdvRBRHxOpQ91Q5doRVjvWW/wepgSdgk5hlaNzfeqpyGmfSD0Eyw== -"@biomejs/cli-win32-arm64@2.2.2": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.2.tgz#26e0fe782de6d83f3ecb4f247322a483104d749a" - integrity sha512-wBe2wItayw1zvtXysmHJQoQqXlTzHSpQRyPpJKiNIR21HzH/CrZRDFic1C1jDdp+zAPtqhNExa0owKMbNwW9cQ== +"@biomejs/cli-win32-arm64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.3.tgz#b8d64ca6dc1c50b8f3d42475afd31b7b44460935" + integrity sha512-Ms9zFYzjcJK7LV+AOMYnjN3pV3xL8Prxf9aWdDVL74onLn5kcvZ1ZMQswE5XHtnd/r/0bnUd928Rpbs14BzVmA== -"@biomejs/cli-win32-x64@2.2.2": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.2.tgz#8c08d82e50b06ad50e4bc54b4bb41428d4261b5c" - integrity sha512-DAuHhHekGfiGb6lCcsT4UyxQmVwQiBCBUMwVra/dcOSs9q8OhfaZgey51MlekT3p8UwRqtXQfFuEJBhJNdLZwg== +"@biomejs/cli-win32-x64@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.3.tgz#ecafffddf0c0675c825735c7cc917cbc8c538433" + integrity sha512-gvCpewE7mBwBIpqk1YrUqNR4mCiyJm6UI3YWQQXkedSSEwzRdodRpaKhbdbHw1/hmTWOVXQ+Eih5Qctf4TCVOQ== "@esbuild/aix-ppc64@0.25.9": version "0.25.9"