commit
238a791a77
72 changed files with 3066 additions and 1845 deletions
|
@ -344,6 +344,15 @@
|
|||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "FabioCingottini",
|
||||
"name": "Fabio Cingottini",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/32102735?v=4",
|
||||
"profile": "https://github.com/FabioCingottini",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
|
|
@ -6,6 +6,7 @@ POSTGRES_PORT=5433
|
|||
APPS_REPO_ID=repo-id
|
||||
APPS_REPO_URL=https://test.com/test
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PASSWORD=redis
|
||||
INTERNAL_IP=localhost
|
||||
TIPI_VERSION=1
|
||||
JWT_SECRET=secret
|
||||
|
|
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
|
@ -14,6 +14,11 @@ env:
|
|||
DOMAIN: localhost
|
||||
LOCAL_DOMAIN: tipi.lan
|
||||
TIPI_VERSION: 0.0.1
|
||||
POSTGRES_HOST: localhost
|
||||
POSTGRES_DBNAME: postgres
|
||||
POSTGRES_USERNAME: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_PORT: 5433
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
|
@ -127,7 +132,7 @@ jobs:
|
|||
run: pnpm install
|
||||
|
||||
- name: Build client
|
||||
run: npm run build:next
|
||||
run: npm run build
|
||||
|
||||
- name: Run tsc
|
||||
run: pnpm run tsc
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,6 +3,7 @@
|
|||
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
|
|
|
@ -17,11 +17,10 @@ COPY ./pnpm-workspace.yaml ./
|
|||
RUN pnpm fetch --no-scripts
|
||||
|
||||
COPY ./package*.json ./
|
||||
COPY ./packages ./packages
|
||||
COPY ./packages/shared ./packages/shared
|
||||
|
||||
RUN pnpm install -r --prefer-offline
|
||||
COPY ./src ./src
|
||||
COPY ./esbuild.js ./esbuild.js
|
||||
COPY ./tsconfig.json ./tsconfig.json
|
||||
COPY ./next.config.mjs ./next.config.mjs
|
||||
COPY ./public ./public
|
||||
|
@ -32,11 +31,11 @@ RUN npm run build
|
|||
# APP
|
||||
FROM node_base AS app
|
||||
|
||||
ENV NODE_ENV production
|
||||
# USER node
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/dist ./
|
||||
COPY --from=builder /app/next.config.mjs ./
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
|
|
@ -11,12 +11,10 @@ COPY ./pnpm-lock.yaml ./
|
|||
RUN pnpm fetch --ignore-scripts
|
||||
|
||||
COPY ./package*.json ./
|
||||
COPY ./packages ./packages
|
||||
COPY ./packages/shared ./packages/shared
|
||||
|
||||
RUN pnpm install -r --prefer-offline
|
||||
|
||||
COPY ./nodemon.json ./nodemon.json
|
||||
COPY ./esbuild.js ./esbuild.js
|
||||
COPY ./tsconfig.json ./tsconfig.json
|
||||
COPY ./next.config.mjs ./next.config.mjs
|
||||
COPY ./public ./public
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# Tipi — A personal homeserver for everyone
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
[](https://github.com/meienberger/runtipi/blob/master/LICENSE)
|
||||
|
@ -118,6 +118,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DireMunchkin"><img src="https://avatars.githubusercontent.com/u/1665676?v=4?s=100" width="100px;" alt="DireMunchkin"/><br /><sub><b>DireMunchkin</b></sub></a><br /><a href="https://github.com/meienberger/runtipi/commits?author=DireMunchkin" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/FabioCingottini"><img src="https://avatars.githubusercontent.com/u/32102735?v=4?s=100" width="100px;" alt="Fabio Cingottini"/><br /><sub><b>Fabio Cingottini</b></sub></a><br /><a href="#translation-FabioCingottini" title="Translation">🌍</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const values = new Map();
|
||||
const expirations = new Map();
|
||||
|
||||
export const createClient = jest.fn(() => {
|
||||
const values = new Map();
|
||||
const expirations = new Map();
|
||||
return {
|
||||
isOpen: true,
|
||||
connect: jest.fn(),
|
||||
|
|
40
esbuild.js
40
esbuild.js
|
@ -1,40 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const esbuild = require('esbuild');
|
||||
const { spawn } = require('child_process');
|
||||
const pkg = require('./package.json');
|
||||
|
||||
const isDev = process.argv[2] !== 'build';
|
||||
|
||||
process.env.NODE_ENV = isDev ? 'development' : 'production';
|
||||
|
||||
let server;
|
||||
const onRebuild = () => {
|
||||
if (isDev) {
|
||||
if (server) server.kill('SIGINT');
|
||||
server = spawn('node', ['dist/index.js'], { stdio: [0, 1, 2] });
|
||||
} else {
|
||||
spawn('pnpm', ['next', 'build'], { stdio: [0, 1, 2] });
|
||||
}
|
||||
};
|
||||
|
||||
const included = ['express', 'pg', '@runtipi/postgres-migrations', 'connect-redis', 'express-session', 'drizzle-orm', '@runtipi/shared'];
|
||||
const excluded = ['pg-native', '*required-server-files.json'];
|
||||
const external = Object.keys(pkg.dependencies || {}).filter((dep) => !included.includes(dep));
|
||||
external.push(...excluded);
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
entryPoints: ['src/server/index.ts'],
|
||||
external,
|
||||
define: { 'process.env.NODE_ENV': `"${process.env.NODE_ENV}"` },
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
outfile: 'dist/index.js',
|
||||
tsconfig: 'tsconfig.json',
|
||||
bundle: true,
|
||||
minify: true,
|
||||
sourcemap: isDev,
|
||||
watch: false,
|
||||
})
|
||||
.finally(onRebuild);
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"watch": ["src/server", "packages/shared"],
|
||||
"exec": "node ./esbuild.js dev",
|
||||
"ext": "js ts"
|
||||
}
|
16
package.json
16
package.json
|
@ -1,26 +1,23 @@
|
|||
{
|
||||
"name": "runtipi",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"description": "A homeserver for everyone",
|
||||
"scripts": {
|
||||
"knip": "knip",
|
||||
"prepare": "mkdir -p state && echo \"{}\" > state/system-info.json && echo \"random-seed\" > state/seed",
|
||||
"copy:migrations": "mkdir -p dist/migrations && cp -r ./src/server/migrations dist",
|
||||
"test": "dotenv -e .env.test -- jest --colors",
|
||||
"test:e2e": "NODE_ENV=test dotenv -e .env -e .env.e2e -- playwright test",
|
||||
"test:e2e:ui": "NODE_ENV=test dotenv -e .env -e .env.e2e -- playwright test --ui",
|
||||
"test:client": "jest --colors --selectProjects client --",
|
||||
"test:server": "jest --colors --selectProjects server --",
|
||||
"test:vite": "dotenv -e .env.test -- vitest run --coverage",
|
||||
"dev": "npm run copy:migrations && npm run db:migrate && nodemon",
|
||||
"dev": "npm run db:migrate && next dev",
|
||||
"dev:watcher": "pnpm -r --filter cli dev",
|
||||
"db:migrate": "NODE_ENV=development dotenv -e .env -- tsx ./src/server/run-migrations-dev.ts",
|
||||
"start": "NODE_ENV=production node index.js",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"build": "npm run copy:migrations && node ./esbuild.js build",
|
||||
"build:server": "node ./esbuild.js build",
|
||||
"build:next": "next build",
|
||||
"build": "next build",
|
||||
"start": "NODE_ENV=production node server.js",
|
||||
"start:dev-container": "./.devcontainer/filewatcher.sh && npm run start:dev",
|
||||
"start:rc": "docker compose -f docker-compose.rc.yml --env-file .env up --build",
|
||||
"start:dev": "npm run prepare && docker compose -f docker-compose.dev.yml up --build",
|
||||
|
@ -60,8 +57,6 @@
|
|||
"connect-redis": "^7.1.0",
|
||||
"cookies-next": "^2.1.2",
|
||||
"drizzle-orm": "^0.27.0",
|
||||
"express": "^4.17.3",
|
||||
"express-session": "^1.17.3",
|
||||
"fs-extra": "^11.1.1",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
|
@ -78,6 +73,7 @@
|
|||
"react-tooltip": "^5.16.1",
|
||||
"redaxios": "^0.5.1",
|
||||
"redis": "^4.6.7",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-breaks": "^3.0.3",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sass": "^1.63.6",
|
||||
|
@ -121,7 +117,6 @@
|
|||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^0.32.2",
|
||||
"dotenv-cli": "^7.2.1",
|
||||
"esbuild": "^0.16.17",
|
||||
"eslint": "8.43.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
|
@ -142,7 +137,6 @@
|
|||
"memfs": "^4.2.0",
|
||||
"msw": "^1.2.2",
|
||||
"next-router-mock": "^0.9.7",
|
||||
"nodemon": "^2.0.22",
|
||||
"prettier": "^2.8.8",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
|
|
|
@ -5,4 +5,4 @@ APPS_REPO_URL=https://test.com/test
|
|||
ROOT_FOLDER_HOST=/runtipi
|
||||
STORAGE_PATH=/runtipi
|
||||
TIPI_VERSION=1
|
||||
|
||||
REDIS_PASSWORD=redis
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@runtipi/cli",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"bin": "dist/index.js",
|
||||
|
@ -43,6 +43,7 @@
|
|||
"vitest": "^0.32.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@runtipi/postgres-migrations": "^5.3.0",
|
||||
"@runtipi/shared": "workspace:^",
|
||||
"axios": "^1.4.0",
|
||||
"boxen": "^7.1.1",
|
||||
|
@ -53,6 +54,7 @@
|
|||
"commander": "^11.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"log-update": "^5.0.1",
|
||||
"pg": "^8.11.1",
|
||||
"semver": "^7.5.3",
|
||||
"systeminformation": "^5.18.7",
|
||||
"web-push": "^3.6.3",
|
||||
|
|
|
@ -1,15 +1,35 @@
|
|||
/* eslint-disable no-await-in-loop */
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import pg from 'pg';
|
||||
import { getEnv } from '@/utils/environment/environment';
|
||||
import { pathExists } from '@/utils/fs-helpers';
|
||||
import { compose } from '@/utils/docker-helpers';
|
||||
import { copyDataDir, generateEnvFile } from './app.helpers';
|
||||
import { fileLogger } from '@/utils/logger/file-logger';
|
||||
import { TerminalSpinner } from '@/utils/logger/terminal-spinner';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const getDbClient = async () => {
|
||||
const { postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getEnv();
|
||||
|
||||
const client = new pg.Client({
|
||||
host: '127.0.0.1',
|
||||
database: postgresDatabase,
|
||||
user: postgresUsername,
|
||||
password: postgresPassword,
|
||||
port: Number(postgresPort),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
export class AppExecutors {
|
||||
private readonly logger;
|
||||
|
||||
|
@ -66,10 +86,15 @@ export class AppExecutors {
|
|||
*/
|
||||
public installApp = async (appId: string, config: Record<string, unknown>) => {
|
||||
try {
|
||||
if (process.getuid && process.getgid) {
|
||||
this.logger.info(`Installing app ${appId} as User ID: ${process.getuid()}, Group ID: ${process.getgid()}`);
|
||||
} else {
|
||||
this.logger.info(`Installing app ${appId}. No User ID or Group ID found.`);
|
||||
}
|
||||
|
||||
const { rootFolderHost, appsRepoId } = getEnv();
|
||||
|
||||
const { appDirPath, repoPath, appDataDirPath } = this.getAppPaths(appId);
|
||||
this.logger.info(`Installing app ${appId}`);
|
||||
|
||||
// Check if app exists in repo
|
||||
const apps = await fs.promises.readdir(path.join(rootFolderHost, 'repos', appsRepoId, 'apps'));
|
||||
|
@ -180,10 +205,14 @@ export class AppExecutors {
|
|||
await compose(appId, 'down --remove-orphans --volumes --rmi all');
|
||||
|
||||
this.logger.info(`Deleting folder ${appDirPath}`);
|
||||
await fs.promises.rm(appDirPath, { recursive: true, force: true });
|
||||
await fs.promises.rm(appDirPath, { recursive: true, force: true }).catch((err) => {
|
||||
this.logger.error(`Error deleting folder ${appDirPath}: ${err.message}`);
|
||||
});
|
||||
|
||||
this.logger.info(`Deleting folder ${appDataDirPath}`);
|
||||
await fs.promises.rm(appDataDirPath, { recursive: true, force: true });
|
||||
await fs.promises.rm(appDataDirPath, { recursive: true, force: true }).catch((err) => {
|
||||
this.logger.error(`Error deleting folder ${appDataDirPath}: ${err.message}`);
|
||||
});
|
||||
|
||||
this.logger.info(`App ${appId} uninstalled`);
|
||||
return { success: true, message: `App ${appId} uninstalled successfully` };
|
||||
|
@ -226,4 +255,43 @@ export class AppExecutors {
|
|||
return this.handleAppError(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start all apps with status running
|
||||
*/
|
||||
public startAllApps = async () => {
|
||||
const spinner = new TerminalSpinner('Starting apps...');
|
||||
const client = await getDbClient();
|
||||
|
||||
try {
|
||||
// Get all apps with status running
|
||||
const { rows } = await client.query(`SELECT * FROM app WHERE status = 'running'`);
|
||||
|
||||
// Update all apps with status different than running or stopped to stopped
|
||||
await client.query(`UPDATE app SET status = 'stopped' WHERE status != 'stopped' AND status != 'running' AND status != 'missing'`);
|
||||
|
||||
// Start all apps
|
||||
for (const row of rows) {
|
||||
spinner.setMessage(`Starting app ${row.id}`);
|
||||
spinner.start();
|
||||
const { id, config } = row;
|
||||
|
||||
const { success } = await this.startApp(id, config);
|
||||
|
||||
if (!success) {
|
||||
this.logger.error(`Error starting app ${id}`);
|
||||
await client.query(`UPDATE app SET status = 'stopped' WHERE id = '${id}'`);
|
||||
spinner.fail(`Error starting app ${id}`);
|
||||
} else {
|
||||
await client.query(`UPDATE app SET status = 'running' WHERE id = '${id}'`);
|
||||
spinner.done(`App ${id} started`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Error starting apps: ${err}`);
|
||||
spinner.fail(`Error starting apps see logs for details (logs/error.log)`);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -2,10 +2,14 @@ import crypto from 'crypto';
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { appInfoSchema, envMapToString, envStringToMap } from '@runtipi/shared';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getEnv } from '@/utils/environment/environment';
|
||||
import { generateVapidKeys, getAppEnvMap } from './env.helpers';
|
||||
import { pathExists } from '@/utils/fs-helpers';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* This function generates a random string of the provided length by using the SHA-256 hash algorithm.
|
||||
* It takes the provided name and a seed value, concatenates them, and uses them as input for the hash algorithm.
|
||||
|
@ -186,4 +190,9 @@ export const copyDataDir = async (id: string) => {
|
|||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Remove any .gitkeep files from the app-data folder at any level
|
||||
if (await pathExists(`${storagePath}/app-data/${id}/data`)) {
|
||||
await execAsync(`find ${storagePath}/app-data/${id}/data -name .gitkeep -delete`).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -81,8 +81,19 @@ export class RepoExecutors {
|
|||
this.logger.info(`stdout: ${stdout}`);
|
||||
});
|
||||
|
||||
// git config pull.rebase false
|
||||
await execAsync(`git -C ${repoPath} config pull.rebase false`).then(({ stdout, stderr }) => {
|
||||
this.logger.info(`------------------ git -C ${repoPath} config pull.rebase false ------------------`);
|
||||
this.logger.error(`stderr: ${stderr}`);
|
||||
this.logger.info(`stdout: ${stdout}`);
|
||||
});
|
||||
|
||||
const currentBranch = await execAsync(`git -C ${repoPath} rev-parse --abbrev-ref HEAD`).then(({ stdout }) => {
|
||||
return stdout.trim();
|
||||
});
|
||||
|
||||
// reset hard
|
||||
await execAsync(`git -C ${repoPath} reset --hard`).then(({ stdout, stderr }) => {
|
||||
await execAsync(`git fetch origin && git -C ${repoPath} reset --hard origin/${currentBranch}`).then(({ stdout, stderr }) => {
|
||||
this.logger.info(`------------------ git -C ${repoPath} reset --hard ------------------`);
|
||||
this.logger.error(`stderr: ${stderr}`);
|
||||
this.logger.info(`stdout: ${stdout}`);
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
/* eslint-disable no-await-in-loop */
|
||||
import { Queue } from 'bullmq';
|
||||
import fs from 'fs';
|
||||
import cliProgress from 'cli-progress';
|
||||
import semver from 'semver';
|
||||
|
@ -9,6 +11,7 @@ import si from 'systeminformation';
|
|||
import { Stream } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import dotenv from 'dotenv';
|
||||
import { SystemEvent } from '@runtipi/shared';
|
||||
import { killOtherWorkers } from 'src/services/watcher/watcher';
|
||||
import chalk from 'chalk';
|
||||
import { AppExecutors } from '../app/app.executors';
|
||||
|
@ -17,6 +20,7 @@ import { TerminalSpinner } from '@/utils/logger/terminal-spinner';
|
|||
import { pathExists } from '@/utils/fs-helpers';
|
||||
import { getEnv } from '@/utils/environment/environment';
|
||||
import { fileLogger } from '@/utils/logger/file-logger';
|
||||
import { runPostgresMigrations } from '@/utils/migrations/run-migration';
|
||||
import { getUserIds } from '@/utils/environment/user';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
@ -54,16 +58,8 @@ export class SystemExecutors {
|
|||
};
|
||||
};
|
||||
|
||||
private ensureFilePermissions = async (rootFolderHost: string, logSudoRequest = true) => {
|
||||
// if we are running as root, we don't need to change permissions
|
||||
if (process.getuid && process.getuid() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logSudoRequest) {
|
||||
const logger = new TerminalSpinner('');
|
||||
logger.log('Tipi needs to change permissions on some files and folders and will ask for your password.');
|
||||
}
|
||||
private ensureFilePermissions = async (rootFolderHost: string) => {
|
||||
const logger = new TerminalSpinner('');
|
||||
|
||||
const filesAndFolders = [
|
||||
path.join(rootFolderHost, 'apps'),
|
||||
|
@ -78,11 +74,25 @@ export class SystemExecutors {
|
|||
path.join(rootFolderHost, 'VERSION'),
|
||||
];
|
||||
|
||||
const files600 = [path.join(rootFolderHost, 'traefik', 'acme.json')];
|
||||
|
||||
// Give permission to read and write to all files and folders for the current user
|
||||
await Promise.all(
|
||||
filesAndFolders.map(async (fileOrFolder) => {
|
||||
if (await pathExists(fileOrFolder)) {
|
||||
await execAsync(`sudo chmod -R a+rwx ${fileOrFolder}`);
|
||||
await execAsync(`sudo chmod -R a+rwx ${fileOrFolder}`).catch(() => {
|
||||
logger.fail(`Failed to set permissions on ${fileOrFolder}`);
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
files600.map(async (fileOrFolder) => {
|
||||
if (await pathExists(fileOrFolder)) {
|
||||
await execAsync(`sudo chmod 600 ${fileOrFolder}`).catch(() => {
|
||||
logger.fail(`Failed to set permissions on ${fileOrFolder}`);
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
@ -114,14 +124,13 @@ export class SystemExecutors {
|
|||
const apps = await fs.promises.readdir(path.join(this.rootFolder, 'apps'));
|
||||
const appExecutor = new AppExecutors();
|
||||
|
||||
await Promise.all(
|
||||
apps.map(async (app) => {
|
||||
const appSpinner = new TerminalSpinner(`Stopping ${app}...`);
|
||||
appSpinner.start();
|
||||
await appExecutor.stopApp(app, {}, true);
|
||||
appSpinner.done(`${app} stopped`);
|
||||
}),
|
||||
);
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const app of apps) {
|
||||
spinner.setMessage(`Stopping ${app}...`);
|
||||
spinner.start();
|
||||
await appExecutor.stopApp(app, {}, true);
|
||||
spinner.done(`${app} stopped`);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.setMessage('Stopping containers...');
|
||||
|
@ -141,17 +150,37 @@ export class SystemExecutors {
|
|||
* This method will start Tipi.
|
||||
* It will copy the system files, generate the system env file, pull the images and start the containers.
|
||||
*/
|
||||
public start = async () => {
|
||||
public start = async (sudo = true) => {
|
||||
const spinner = new TerminalSpinner('Starting Tipi...');
|
||||
try {
|
||||
const { isSudo } = getUserIds();
|
||||
|
||||
if (!isSudo) {
|
||||
if (!sudo) {
|
||||
console.log(
|
||||
boxen(
|
||||
"You are running in sudoless mode. While Tipi should work as expected, you'll probably run into permission issues and will have to manually fix them. We recommend running Tipi with sudo for beginners.",
|
||||
{
|
||||
title: '⛔️Sudoless mode',
|
||||
titleAlignment: 'center',
|
||||
textAlignment: 'center',
|
||||
padding: 1,
|
||||
borderStyle: 'double',
|
||||
borderColor: 'red',
|
||||
margin: { top: 1, bottom: 1 },
|
||||
width: 80,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSudo && sudo) {
|
||||
console.log(chalk.red('Tipi needs to run as root to start. Use sudo ./runtipi-cli start'));
|
||||
throw new Error('Tipi needs to run as root to start. Use sudo ./runtipi-cli start');
|
||||
}
|
||||
|
||||
await this.ensureFilePermissions(this.rootFolder);
|
||||
if (sudo) {
|
||||
await this.ensureFilePermissions(this.rootFolder);
|
||||
}
|
||||
|
||||
spinner.start();
|
||||
spinner.setMessage('Copying system files...');
|
||||
|
@ -159,7 +188,9 @@ export class SystemExecutors {
|
|||
|
||||
spinner.done('System files copied');
|
||||
|
||||
await this.ensureFilePermissions(this.rootFolder, false);
|
||||
if (sudo) {
|
||||
await this.ensureFilePermissions(this.rootFolder);
|
||||
}
|
||||
|
||||
spinner.setMessage('Generating system env file...');
|
||||
spinner.start();
|
||||
|
@ -208,13 +239,45 @@ export class SystemExecutors {
|
|||
|
||||
spinner.done('Watcher started');
|
||||
|
||||
const queue = new Queue('events', { connection: { host: '127.0.0.1', port: 6379, password: envMap.get('REDIS_PASSWORD') } });
|
||||
await queue.obliterate({ force: true });
|
||||
|
||||
// Initial jobs
|
||||
await queue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent);
|
||||
await queue.add(`${Math.random().toString()}_repo_clone`, { type: 'repo', command: 'clone', url: envMap.get('APPS_REPO_URL') } as SystemEvent);
|
||||
|
||||
// Scheduled jobs
|
||||
await queue.add(`${Math.random().toString()}_repo_update`, { type: 'repo', command: 'update', url: envMap.get('APPS_REPO_URL') } as SystemEvent, { repeat: { pattern: '*/30 * * * *' } });
|
||||
await queue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent, { repeat: { pattern: '* * * * *' } });
|
||||
|
||||
await queue.close();
|
||||
|
||||
spinner.setMessage('Running database migrations...');
|
||||
spinner.start();
|
||||
|
||||
await runPostgresMigrations({
|
||||
postgresHost: '127.0.0.1',
|
||||
postgresDatabase: envMap.get('POSTGRES_DBNAME') as string,
|
||||
postgresUsername: envMap.get('POSTGRES_USERNAME') as string,
|
||||
postgresPassword: envMap.get('POSTGRES_PASSWORD') as string,
|
||||
postgresPort: envMap.get('POSTGRES_PORT') as string,
|
||||
});
|
||||
|
||||
spinner.done('Database migrations complete');
|
||||
|
||||
// Start all apps
|
||||
const appExecutor = new AppExecutors();
|
||||
await appExecutor.startAllApps();
|
||||
|
||||
console.log(
|
||||
boxen(`Visit: http://${envMap.get('INTERNAL_IP')}:${envMap.get('NGINX_PORT')} to access the dashboard\n\nFind documentation and guides at: https://runtipi.io`, {
|
||||
title: 'Tipi successfully started 🎉',
|
||||
titleAlignment: 'center',
|
||||
textAlignment: 'center',
|
||||
padding: 1,
|
||||
borderStyle: 'double',
|
||||
borderColor: 'green',
|
||||
width: 80,
|
||||
margin: { top: 1 },
|
||||
}),
|
||||
);
|
||||
|
@ -265,7 +328,7 @@ export class SystemExecutors {
|
|||
|
||||
if (!targetVersion || targetVersion === 'latest') {
|
||||
spinner.setMessage('Fetching latest version...');
|
||||
const { data } = await axios.get<{ tag_name: string }>('https://api.github.com/repos/meienberger/runtipi/releases');
|
||||
const { data } = await axios.get<{ tag_name: string }>('https://api.github.com/repos/meienberger/runtipi/releases/latest');
|
||||
targetVersion = data.tag_name;
|
||||
}
|
||||
|
||||
|
@ -357,6 +420,8 @@ export class SystemExecutors {
|
|||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
spinner.done(`Tipi ${targetVersion} successfully updated. Please run './runtipi-cli start' to start Tipi again.`);
|
||||
|
||||
return { success: true, message: 'Tipi updated' };
|
||||
} catch (e) {
|
||||
spinner.fail('Tipi update failed, see logs for more details (logs/error.log)');
|
||||
|
|
|
@ -33,6 +33,8 @@ type EnvKeys =
|
|||
| 'REDIS_PASSWORD'
|
||||
| 'LOCAL_DOMAIN'
|
||||
| 'DEMO_MODE'
|
||||
| 'TIPI_GID'
|
||||
| 'TIPI_UID'
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
| (string & {});
|
||||
|
||||
|
@ -177,6 +179,12 @@ export const generateSystemEnvFile = async () => {
|
|||
envMap.set('LOCAL_DOMAIN', data.localDomain || 'tipi.lan');
|
||||
envMap.set('NODE_ENV', 'production');
|
||||
|
||||
const currentUserGroup = process.getgid ? String(process.getgid()) : '1000';
|
||||
const currentUserId = process.getuid ? String(process.getuid()) : '1000';
|
||||
|
||||
envMap.set('TIPI_GID', currentUserGroup);
|
||||
envMap.set('TIPI_UID', currentUserId);
|
||||
|
||||
await fs.promises.writeFile(envFilePath, envMapToString(envMap));
|
||||
|
||||
return envMap;
|
||||
|
|
|
@ -22,9 +22,9 @@ const main = async () => {
|
|||
.description('Start tipi')
|
||||
.option('--no-permissions', 'Skip permissions check')
|
||||
.option('--no-sudo', 'Skip sudo usage')
|
||||
.action(async () => {
|
||||
.action(async (options) => {
|
||||
const systemExecutors = new SystemExecutors();
|
||||
await systemExecutors.start();
|
||||
await systemExecutors.start(options.sudo);
|
||||
});
|
||||
|
||||
program
|
||||
|
|
|
@ -85,7 +85,11 @@ export const killOtherWorkers = async () => {
|
|||
|
||||
pids.concat(pidsInherit).forEach((pid) => {
|
||||
console.log(`Killing worker with pid ${pid}`);
|
||||
process.kill(Number(pid));
|
||||
try {
|
||||
process.kill(Number(pid));
|
||||
} catch (e) {
|
||||
console.error(`Error killing worker with pid ${pid}: ${e}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -16,9 +16,14 @@ const environmentSchema = z
|
|||
INTERNAL_IP: z.string().ip().or(z.literal('localhost')),
|
||||
TIPI_VERSION: z.string(),
|
||||
REDIS_PASSWORD: z.string(),
|
||||
POSTGRES_PORT: z.string(),
|
||||
POSTGRES_USERNAME: z.string(),
|
||||
POSTGRES_PASSWORD: z.string(),
|
||||
POSTGRES_DBNAME: z.string(),
|
||||
})
|
||||
.transform((env) => {
|
||||
const { STORAGE_PATH, ARCHITECTURE, ROOT_FOLDER_HOST, APPS_REPO_ID, INTERNAL_IP, TIPI_VERSION, REDIS_PASSWORD, ...rest } = env;
|
||||
const { STORAGE_PATH, ARCHITECTURE, ROOT_FOLDER_HOST, APPS_REPO_ID, INTERNAL_IP, TIPI_VERSION, REDIS_PASSWORD, POSTGRES_DBNAME, POSTGRES_PASSWORD, POSTGRES_USERNAME, POSTGRES_PORT, ...rest } =
|
||||
env;
|
||||
|
||||
return {
|
||||
storagePath: STORAGE_PATH,
|
||||
|
@ -28,6 +33,10 @@ const environmentSchema = z
|
|||
tipiVersion: TIPI_VERSION,
|
||||
internalIp: INTERNAL_IP,
|
||||
redisPassword: REDIS_PASSWORD,
|
||||
postgresPort: POSTGRES_PORT,
|
||||
postgresUsername: POSTGRES_USERNAME,
|
||||
postgresPassword: POSTGRES_PASSWORD,
|
||||
postgresDatabase: POSTGRES_DBNAME,
|
||||
...rest,
|
||||
};
|
||||
});
|
||||
|
|
56
packages/cli/src/utils/migrations/run-migration.ts
Normal file
56
packages/cli/src/utils/migrations/run-migration.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import path from 'path';
|
||||
import pg from 'pg';
|
||||
import { migrate } from '@runtipi/postgres-migrations';
|
||||
import { fileLogger } from '../logger/file-logger';
|
||||
|
||||
type MigrationParams = {
|
||||
postgresHost: string;
|
||||
postgresDatabase: string;
|
||||
postgresUsername: string;
|
||||
postgresPassword: string;
|
||||
postgresPort: string;
|
||||
};
|
||||
|
||||
export const runPostgresMigrations = async (params: MigrationParams) => {
|
||||
const assetsFolder = path.join('/snapshot', 'runtipi', 'packages', 'cli', 'assets');
|
||||
|
||||
const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = params;
|
||||
|
||||
fileLogger.info('Starting database migration');
|
||||
|
||||
fileLogger.info(`Connecting to database ${postgresDatabase} on ${postgresHost} as ${postgresUsername} on port ${postgresPort}`);
|
||||
|
||||
const client = new pg.Client({
|
||||
user: postgresUsername,
|
||||
host: postgresHost,
|
||||
database: postgresDatabase,
|
||||
password: postgresPassword,
|
||||
port: Number(postgresPort),
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
fileLogger.info('Client connected');
|
||||
|
||||
try {
|
||||
const { rows } = await client.query('SELECT * FROM migrations');
|
||||
// if rows contains a migration with name 'Initial1657299198975' (legacy typeorm) delete table migrations. As all migrations are idempotent we can safely delete the table and start over.
|
||||
if (rows.find((row) => row.name === 'Initial1657299198975')) {
|
||||
fileLogger.info('Found legacy migration. Deleting table migrations');
|
||||
await client.query('DROP TABLE migrations');
|
||||
}
|
||||
} catch (e) {
|
||||
fileLogger.info('Migrations table not found, creating it');
|
||||
}
|
||||
|
||||
fileLogger.info('Running migrations');
|
||||
try {
|
||||
await migrate({ client }, path.join(assetsFolder, 'migrations'), { skipCreateMigrationTable: true });
|
||||
} catch (e) {
|
||||
fileLogger.error('Error running migrations. Dropping table migrations and trying again');
|
||||
await client.query('DROP TABLE migrations');
|
||||
await migrate({ client }, path.join(assetsFolder, 'migrations'), { skipCreateMigrationTable: true });
|
||||
}
|
||||
|
||||
fileLogger.info('Migration complete');
|
||||
await client.end();
|
||||
};
|
2061
pnpm-lock.yaml
generated
2061
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -8,8 +8,8 @@ echo "Installing runtipi..."
|
|||
ARCHITECTURE="$(uname -m)"
|
||||
# Not supported on 32 bits systems
|
||||
if [[ "$ARCHITECTURE" == "armv7"* ]] || [[ "$ARCHITECTURE" == "i686" ]] || [[ "$ARCHITECTURE" == "i386" ]]; then
|
||||
echo "runtipi is not supported on 32 bits systems"
|
||||
exit 1
|
||||
echo "runtipi is not supported on 32 bits systems"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
### --------------------------------
|
||||
|
@ -178,8 +178,8 @@ fi
|
|||
URL="https://github.com/meienberger/runtipi/releases/download/$VERSION/$ASSET"
|
||||
|
||||
if [[ "${UPDATE}" == "false" ]]; then
|
||||
mkdir -p runtipi
|
||||
cd runtipi || exit
|
||||
mkdir -p runtipi
|
||||
cd runtipi || exit
|
||||
fi
|
||||
|
||||
curl --location "$URL" -o ./runtipi-cli
|
||||
|
@ -188,11 +188,8 @@ chmod +x ./runtipi-cli
|
|||
# Check if user is in docker group
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if ! groups | grep -q docker; then
|
||||
echo ""
|
||||
echo "User is not in docker group. Please make sure your user is allowed to run docker commands and restart the script."
|
||||
echo "See https://docs.docker.com/engine/install/linux-postinstall/ for more information."
|
||||
echo ""
|
||||
exit 1
|
||||
sudo usermod -aG docker "$USER"
|
||||
newgrp docker
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
31
src/@types/next.d.ts
vendored
31
src/@types/next.d.ts
vendored
|
@ -1,31 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { IncomingMessage } from 'http';
|
||||
import { Session } from 'express-session';
|
||||
import { GetServerSidePropsContext, GetServerSidePropsResult, PreviewData } from 'next';
|
||||
import { ParsedUrlQuery } from 'querystring';
|
||||
import { Locale } from '@/shared/internationalization/locales';
|
||||
|
||||
type SessionContent = {
|
||||
userId?: number;
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData extends SessionContent {
|
||||
userId?: number;
|
||||
}
|
||||
}
|
||||
|
||||
interface ExtendedGetServerSidePropsContext<Params, Preview> extends GetServerSidePropsContext<Params, Preview> {
|
||||
req: IncomingMessage & { session: Session & SessionContent } & { cookies?: { locale?: string } };
|
||||
}
|
||||
|
||||
declare module 'next' {
|
||||
export interface NextApiRequest extends IncomingMessage {
|
||||
session: Session & SessionContent;
|
||||
}
|
||||
|
||||
export type GetServerSideProps<Props extends { [key: string]: any } = { [key: string]: any }, Params extends ParsedUrlQuery = ParsedUrlQuery, Preview extends PreviewData = PreviewData> = (
|
||||
ctx: ExtendedGetServerSidePropsContext<Params, Preview>,
|
||||
) => Promise<GetServerSidePropsResult<Props>>;
|
||||
}
|
|
@ -4,7 +4,7 @@ import { getUrl } from '../../core/helpers/url-helpers';
|
|||
import styles from './AppLogo.module.scss';
|
||||
|
||||
export const AppLogo: React.FC<{ id?: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
|
||||
const logoUrl = id ? `/static/apps/${id}/metadata/logo.jpg` : getUrl('placeholder.png');
|
||||
const logoUrl = id ? `/api/app-image?id=${id}` : getUrl('placeholder.png');
|
||||
|
||||
return (
|
||||
<div aria-label={alt} className={clsx(styles.dropShadow, className)} style={{ width: size, height: size }}>
|
||||
|
|
|
@ -3,6 +3,8 @@ import React from 'react';
|
|||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { PluggableList } from 'react-markdown/lib/react-markdown';
|
||||
|
||||
const MarkdownImg = (props: Pick<React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, 'key' | keyof React.ImgHTMLAttributes<HTMLImageElement>>) => (
|
||||
<div className="d-flex justify-content-center">
|
||||
|
@ -24,6 +26,7 @@ const Markdown: React.FC<{ children: string; className: string }> = ({ children,
|
|||
// div: (props) => <div {...props} className="mb-4" />,
|
||||
}}
|
||||
remarkPlugins={[remarkBreaks, remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw] as PluggableList}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
|
|
299
src/client/messages/ach-UG.json
Normal file
299
src/client/messages/ach-UG.json
Normal file
|
@ -0,0 +1,299 @@
|
|||
{
|
||||
"server-messages": {
|
||||
"errors": {
|
||||
"invalid-credentials": "Invalid credentials",
|
||||
"admin-already-exists": "There is already an admin user. Please login to create a new user from the admin panel.",
|
||||
"missing-email-or-password": "Missing email or password",
|
||||
"invalid-username": "Invalid username",
|
||||
"user-already-exists": "User already exists",
|
||||
"error-creating-user": "Error creating user",
|
||||
"no-change-password-request": "No change password request found",
|
||||
"operator-not-found": "Operator user not found",
|
||||
"user-not-found": "User not found",
|
||||
"not-allowed-in-demo": "Not allowed in demo mode",
|
||||
"not-allowed-in-dev": "Not allowed in dev mode",
|
||||
"invalid-password": "Invalid password",
|
||||
"invalid-password-length": "Password must be at least 8 characters long",
|
||||
"invalid-locale": "Invalid locale",
|
||||
"totp-session-not-found": "2FA session not found",
|
||||
"totp-not-enabled": "2FA is not enabled for this user",
|
||||
"totp-invalid-code": "Invalid 2FA code",
|
||||
"totp-already-enabled": "2FA is already enabled for this user",
|
||||
"app-not-found": "App {id} not found",
|
||||
"app-failed-to-start": "Failed to start app {id}, see logs for more details",
|
||||
"app-failed-to-install": "Failed to install app {id}, see logs for more details",
|
||||
"app-failed-to-stop": "Failed to stop app {id}, see logs for more details",
|
||||
"app-failed-to-uninstall": "Failed to uninstall app {id}, see logs for more details",
|
||||
"app-failed-to-update": "Failed to update app {id}, see logs for more details",
|
||||
"domain-required-if-expose-app": "Domain is required if app is exposed",
|
||||
"domain-not-valid": "Domain {domain} is not a valid domain",
|
||||
"invalid-config": "App {id} has an invalid config.json file",
|
||||
"app-not-exposable": "App {id} is not exposable",
|
||||
"app-force-exposed": "App {id} works only with exposed domain",
|
||||
"domain-already-in-use": "Domain {domain} is already in use by app {id}",
|
||||
"could-not-get-latest-version": "Could not get latest version",
|
||||
"current-version-is-latest": "Current version is already up to date",
|
||||
"major-version-update": "The major version has changed. Please update manually (instructions on GitHub)"
|
||||
},
|
||||
"success": {}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Login to your account",
|
||||
"submit": "Login"
|
||||
},
|
||||
"totp": {
|
||||
"title": "Two-factor authentication",
|
||||
"instructions": "Enter the code from your authenticator app",
|
||||
"submit": "Confirm"
|
||||
},
|
||||
"register": {
|
||||
"title": "Register your account",
|
||||
"submit": "Register"
|
||||
},
|
||||
"reset-password": {
|
||||
"title": "Reset your password",
|
||||
"submit": "Reset password",
|
||||
"cancel": "Cancel password change request",
|
||||
"instructions": "Run this command on your server and then refresh this page",
|
||||
"success-title": "Password reset",
|
||||
"success": "Your password has been reset. You can now login with your new password. And your email {email}",
|
||||
"back-to-login": "Back to login"
|
||||
},
|
||||
"form": {
|
||||
"email": "Email address",
|
||||
"email-placeholder": "you@example.com",
|
||||
"password": "Password",
|
||||
"password-placeholder": "Enter your password",
|
||||
"password-confirmation": "Confirm password",
|
||||
"password-confirmation-placeholder": "Confirm your password",
|
||||
"forgot": "Forgot password?",
|
||||
"new-password-placeholder": "Your new password",
|
||||
"new-password-confirmation-placeholder": "Confirm your new password",
|
||||
"errors": {
|
||||
"email": {
|
||||
"required": "Email address is required",
|
||||
"email": "Email address is invalid",
|
||||
"invalid": "Email address is invalid"
|
||||
},
|
||||
"password": {
|
||||
"required": "Password is required",
|
||||
"minlength": "Password must be at least 8 characters"
|
||||
},
|
||||
"password-confirmation": {
|
||||
"required": "Password confirmation is required",
|
||||
"minlength": "Password confirmation must be at least 8 characters",
|
||||
"match": "Passwords do not match"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"cards": {
|
||||
"disk": {
|
||||
"title": "Disk Space",
|
||||
"subtitle": "Used out of {total} GB"
|
||||
},
|
||||
"memory": {
|
||||
"title": "Memory Used"
|
||||
},
|
||||
"cpu": {
|
||||
"title": "CPU Load",
|
||||
"subtitle": "Uninstall apps to reduce load"
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"status-running": "Running",
|
||||
"status-stopped": "Stopped",
|
||||
"status-starting": "Starting",
|
||||
"status-stopping": "Stopping",
|
||||
"status-updating": "Updating",
|
||||
"status-missing": "Missing",
|
||||
"status-installing": "Installing",
|
||||
"status-uninstalling": "Uninstalling",
|
||||
"update-available": "Update available",
|
||||
"my-apps": {
|
||||
"title": "My Apps",
|
||||
"empty-title": "No app installed",
|
||||
"empty-subtitle": "Install an app from the app store to get started",
|
||||
"empty-action": "Go to app store"
|
||||
},
|
||||
"app-store": {
|
||||
"title": "App Store",
|
||||
"search-placeholder": "Search apps",
|
||||
"category-placeholder": "Select a category",
|
||||
"no-results": "No app found",
|
||||
"no-results-subtitle": "Try to refine your search"
|
||||
},
|
||||
"app-details": {
|
||||
"install-success": "App installed successfully",
|
||||
"uninstall-success": "App uninstalled successfully",
|
||||
"stop-success": "App stopped successfully",
|
||||
"update-success": "App updated successfully",
|
||||
"start-success": "App started successfully",
|
||||
"update-config-success": "App config updated successfully. Restart the app to apply the changes",
|
||||
"version": "Version",
|
||||
"description": "Description",
|
||||
"base-info": "Base info",
|
||||
"source-code": "Source code",
|
||||
"author": "Author",
|
||||
"port": "Port",
|
||||
"categories-title": "Categories",
|
||||
"link": "Link",
|
||||
"website": "Website",
|
||||
"supported-arch": "Supported architectures",
|
||||
"choose-open-method": "Choose open method",
|
||||
"categories": {
|
||||
"data": "Data",
|
||||
"network": "Network",
|
||||
"media": "Media",
|
||||
"development": "Development",
|
||||
"automation": "Automation",
|
||||
"social": "Social",
|
||||
"utilities": "Utilities",
|
||||
"security": "Security",
|
||||
"photography": "Photography",
|
||||
"featured": "Featured",
|
||||
"books": "Books",
|
||||
"music": "Music",
|
||||
"finance": "Finance",
|
||||
"gaming": "Gaming",
|
||||
"ai": "AI"
|
||||
},
|
||||
"actions": {
|
||||
"start": "Start",
|
||||
"remove": "Remove",
|
||||
"settings": "Settings",
|
||||
"stop": "Stop",
|
||||
"open": "Open",
|
||||
"loading": "Loading",
|
||||
"cancel": "Cancel",
|
||||
"install": "Install",
|
||||
"update": "Update"
|
||||
},
|
||||
"install-form": {
|
||||
"title": "Install {name}",
|
||||
"expose-app": "Expose app",
|
||||
"domain-name": "Domain name",
|
||||
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
|
||||
"choose-option": "Choose an option...",
|
||||
"sumbit-install": "Install",
|
||||
"submit-update": "Update",
|
||||
"errors": {
|
||||
"required": "{label} is required",
|
||||
"regex": "{label} must match the pattern {pattern}",
|
||||
"max-length": "{label} must be less than {max} characters",
|
||||
"min-length": "{label} must be at least {min} characters",
|
||||
"between-length": "{label} must be between {min} and {max} characters",
|
||||
"invalid-email": "{label} must be a valid email address",
|
||||
"number": "{label} must be a number",
|
||||
"fqdn": "{label} must be a valid domain",
|
||||
"ip": "{label} must be a valid IP address",
|
||||
"fqdnip": "{label} must be a valid domain or IP address",
|
||||
"url": "{label} must be a valid URL"
|
||||
}
|
||||
},
|
||||
"stop-form": {
|
||||
"title": "Stop {name} ?",
|
||||
"subtitle": "All data will be retained",
|
||||
"submit": "Stop"
|
||||
},
|
||||
"uninstall-form": {
|
||||
"title": "Uninstall {name} ?",
|
||||
"subtitle": "All data for this app will be lost.",
|
||||
"warning": "Are you sure? This action cannot be undone.",
|
||||
"submit": "Uninstall"
|
||||
},
|
||||
"update-form": {
|
||||
"title": "Update {name} ?",
|
||||
"subtitle1": "Update app to latest verion :",
|
||||
"subtitle2": "This will reset your custom configuration (e.g. changes in docker-compose.yml)",
|
||||
"submit": "Update"
|
||||
},
|
||||
"update-settings-form": {
|
||||
"title": "Update {name} config"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"actions": {
|
||||
"tab-title": "Actions",
|
||||
"title": "Actions",
|
||||
"current-version": "Current version: {version}",
|
||||
"stay-up-to-date": "Stay up to date with the latest version of Tipi",
|
||||
"new-version": "A new version ({version}) of Tipi is available",
|
||||
"maintenance-title": "Maintenance",
|
||||
"maintenance-subtitle": "Common actions to perform on your instance",
|
||||
"restart": "Restart",
|
||||
"update": "Update to {version}",
|
||||
"already-latest": "Already up to date"
|
||||
},
|
||||
"settings": {
|
||||
"tab-title": "Settings",
|
||||
"title": "General settings",
|
||||
"subtitle": "This will update your settings.json file. Make sure you know what you are doing before updating these values.",
|
||||
"settings-updated": "Settings updated. Restart your instance to apply new settings.",
|
||||
"invalid-ip": "Invalid IP address",
|
||||
"invalid-url": "Invalid URL",
|
||||
"invalid-domain": "Invalid domain",
|
||||
"domain-name": "Domain name",
|
||||
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
|
||||
"dns-ip": "DNS IP",
|
||||
"internal-ip": "Internal IP",
|
||||
"internal-ip-hint": "IP address your server is listening on.",
|
||||
"apps-repo": "Apps repo URL",
|
||||
"apps-repo-hint": "URL to the apps repository.",
|
||||
"storage-path": "Storage path",
|
||||
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
|
||||
"local-domain": "Local domain",
|
||||
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
|
||||
"submit": "Save",
|
||||
"user-settings-title": "User settings",
|
||||
"language": "Language",
|
||||
"help-translate": "Help translate Tipi",
|
||||
"download-certificate": "Download certificate"
|
||||
},
|
||||
"security": {
|
||||
"tab-title": "Security",
|
||||
"change-password-title": "Change password",
|
||||
"change-password-subtitle": "Changing your password will log you out of all devices.",
|
||||
"password-change-success": "Password changed successfully",
|
||||
"2fa-title": "Two-factor authentication",
|
||||
"2fa-subtitle": "Two-factor authentication (2FA) adds an additional layer of security to your account.",
|
||||
"2fa-subtitle-2": "When enabled, you will be prompted to enter a code from your authenticator app when you log in.",
|
||||
"2fa-enable-success": "Two-factor authentication enabled",
|
||||
"2fa-disable-success": "Two-factor authentication disabled",
|
||||
"scan-qr-code": "Scan this QR code with your authenticator app.",
|
||||
"enter-key-manually": "Or enter this key manually.",
|
||||
"enter-2fa-code": "Enter the 6-digit code from your authenticator app",
|
||||
"enable-2fa": "Enable two-factor authentication",
|
||||
"disable-2fa": "Disable two-factor authentication",
|
||||
"password-needed": "Password needed",
|
||||
"password-needed-hint": "Your password is required to change two-factor authentication settings.",
|
||||
"form": {
|
||||
"password-length": "Password must be at least 8 characters",
|
||||
"password-match": "Passwords do not match",
|
||||
"current-password": "Current password",
|
||||
"new-password": "New password",
|
||||
"confirm-password": "Confirm new password",
|
||||
"change-password": "Change password",
|
||||
"password": "Password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"dashboard": "Dashboard",
|
||||
"my-apps": "My Apps",
|
||||
"app-store": "App Store",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout",
|
||||
"dark-mode": "Dark Mode",
|
||||
"light-mode": "Light Mode",
|
||||
"sponsor": "Sponsor",
|
||||
"source-code": "Source code",
|
||||
"update-available": "Update available"
|
||||
}
|
||||
}
|
299
src/client/messages/ar-SA.json
Normal file
299
src/client/messages/ar-SA.json
Normal file
|
@ -0,0 +1,299 @@
|
|||
{
|
||||
"server-messages": {
|
||||
"errors": {
|
||||
"invalid-credentials": "Invalid credentials",
|
||||
"admin-already-exists": "There is already an admin user. Please login to create a new user from the admin panel.",
|
||||
"missing-email-or-password": "Missing email or password",
|
||||
"invalid-username": "Invalid username",
|
||||
"user-already-exists": "User already exists",
|
||||
"error-creating-user": "Error creating user",
|
||||
"no-change-password-request": "No change password request found",
|
||||
"operator-not-found": "Operator user not found",
|
||||
"user-not-found": "User not found",
|
||||
"not-allowed-in-demo": "Not allowed in demo mode",
|
||||
"not-allowed-in-dev": "Not allowed in dev mode",
|
||||
"invalid-password": "Invalid password",
|
||||
"invalid-password-length": "Password must be at least 8 characters long",
|
||||
"invalid-locale": "Invalid locale",
|
||||
"totp-session-not-found": "2FA session not found",
|
||||
"totp-not-enabled": "2FA is not enabled for this user",
|
||||
"totp-invalid-code": "Invalid 2FA code",
|
||||
"totp-already-enabled": "2FA is already enabled for this user",
|
||||
"app-not-found": "App {id} not found",
|
||||
"app-failed-to-start": "Failed to start app {id}, see logs for more details",
|
||||
"app-failed-to-install": "Failed to install app {id}, see logs for more details",
|
||||
"app-failed-to-stop": "Failed to stop app {id}, see logs for more details",
|
||||
"app-failed-to-uninstall": "Failed to uninstall app {id}, see logs for more details",
|
||||
"app-failed-to-update": "Failed to update app {id}, see logs for more details",
|
||||
"domain-required-if-expose-app": "Domain is required if app is exposed",
|
||||
"domain-not-valid": "Domain {domain} is not a valid domain",
|
||||
"invalid-config": "App {id} has an invalid config.json file",
|
||||
"app-not-exposable": "App {id} is not exposable",
|
||||
"app-force-exposed": "App {id} works only with exposed domain",
|
||||
"domain-already-in-use": "Domain {domain} is already in use by app {id}",
|
||||
"could-not-get-latest-version": "Could not get latest version",
|
||||
"current-version-is-latest": "Current version is already up to date",
|
||||
"major-version-update": "The major version has changed. Please update manually (instructions on GitHub)"
|
||||
},
|
||||
"success": {}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Login to your account",
|
||||
"submit": "Login"
|
||||
},
|
||||
"totp": {
|
||||
"title": "Two-factor authentication",
|
||||
"instructions": "Enter the code from your authenticator app",
|
||||
"submit": "Confirm"
|
||||
},
|
||||
"register": {
|
||||
"title": "Register your account",
|
||||
"submit": "Register"
|
||||
},
|
||||
"reset-password": {
|
||||
"title": "Reset your password",
|
||||
"submit": "Reset password",
|
||||
"cancel": "Cancel password change request",
|
||||
"instructions": "Run this command on your server and then refresh this page",
|
||||
"success-title": "Password reset",
|
||||
"success": "Your password has been reset. You can now login with your new password. And your email {email}",
|
||||
"back-to-login": "Back to login"
|
||||
},
|
||||
"form": {
|
||||
"email": "Email address",
|
||||
"email-placeholder": "you@example.com",
|
||||
"password": "Password",
|
||||
"password-placeholder": "Enter your password",
|
||||
"password-confirmation": "Confirm password",
|
||||
"password-confirmation-placeholder": "Confirm your password",
|
||||
"forgot": "Forgot password?",
|
||||
"new-password-placeholder": "Your new password",
|
||||
"new-password-confirmation-placeholder": "Confirm your new password",
|
||||
"errors": {
|
||||
"email": {
|
||||
"required": "Email address is required",
|
||||
"email": "Email address is invalid",
|
||||
"invalid": "Email address is invalid"
|
||||
},
|
||||
"password": {
|
||||
"required": "Password is required",
|
||||
"minlength": "Password must be at least 8 characters"
|
||||
},
|
||||
"password-confirmation": {
|
||||
"required": "Password confirmation is required",
|
||||
"minlength": "Password confirmation must be at least 8 characters",
|
||||
"match": "Passwords do not match"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"cards": {
|
||||
"disk": {
|
||||
"title": "Disk Space",
|
||||
"subtitle": "Used out of {total} GB"
|
||||
},
|
||||
"memory": {
|
||||
"title": "Memory Used"
|
||||
},
|
||||
"cpu": {
|
||||
"title": "CPU Load",
|
||||
"subtitle": "Uninstall apps to reduce load"
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"status-running": "Running",
|
||||
"status-stopped": "Stopped",
|
||||
"status-starting": "Starting",
|
||||
"status-stopping": "Stopping",
|
||||
"status-updating": "Updating",
|
||||
"status-missing": "Missing",
|
||||
"status-installing": "Installing",
|
||||
"status-uninstalling": "Uninstalling",
|
||||
"update-available": "Update available",
|
||||
"my-apps": {
|
||||
"title": "My Apps",
|
||||
"empty-title": "No app installed",
|
||||
"empty-subtitle": "Install an app from the app store to get started",
|
||||
"empty-action": "Go to app store"
|
||||
},
|
||||
"app-store": {
|
||||
"title": "App Store",
|
||||
"search-placeholder": "Search apps",
|
||||
"category-placeholder": "Select a category",
|
||||
"no-results": "No app found",
|
||||
"no-results-subtitle": "Try to refine your search"
|
||||
},
|
||||
"app-details": {
|
||||
"install-success": "App installed successfully",
|
||||
"uninstall-success": "App uninstalled successfully",
|
||||
"stop-success": "App stopped successfully",
|
||||
"update-success": "App updated successfully",
|
||||
"start-success": "App started successfully",
|
||||
"update-config-success": "App config updated successfully. Restart the app to apply the changes",
|
||||
"version": "Version",
|
||||
"description": "Description",
|
||||
"base-info": "Base info",
|
||||
"source-code": "Source code",
|
||||
"author": "Author",
|
||||
"port": "Port",
|
||||
"categories-title": "Categories",
|
||||
"link": "Link",
|
||||
"website": "Website",
|
||||
"supported-arch": "Supported architectures",
|
||||
"choose-open-method": "Choose open method",
|
||||
"categories": {
|
||||
"data": "Data",
|
||||
"network": "Network",
|
||||
"media": "Media",
|
||||
"development": "Development",
|
||||
"automation": "Automation",
|
||||
"social": "Social",
|
||||
"utilities": "Utilities",
|
||||
"security": "Security",
|
||||
"photography": "Photography",
|
||||
"featured": "Featured",
|
||||
"books": "Books",
|
||||
"music": "Music",
|
||||
"finance": "Finance",
|
||||
"gaming": "Gaming",
|
||||
"ai": "AI"
|
||||
},
|
||||
"actions": {
|
||||
"start": "Start",
|
||||
"remove": "Remove",
|
||||
"settings": "Settings",
|
||||
"stop": "Stop",
|
||||
"open": "Open",
|
||||
"loading": "Loading",
|
||||
"cancel": "Cancel",
|
||||
"install": "Install",
|
||||
"update": "Update"
|
||||
},
|
||||
"install-form": {
|
||||
"title": "Install {name}",
|
||||
"expose-app": "Expose app",
|
||||
"domain-name": "Domain name",
|
||||
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
|
||||
"choose-option": "Choose an option...",
|
||||
"sumbit-install": "Install",
|
||||
"submit-update": "Update",
|
||||
"errors": {
|
||||
"required": "{label} is required",
|
||||
"regex": "{label} must match the pattern {pattern}",
|
||||
"max-length": "{label} must be less than {max} characters",
|
||||
"min-length": "{label} must be at least {min} characters",
|
||||
"between-length": "{label} must be between {min} and {max} characters",
|
||||
"invalid-email": "{label} must be a valid email address",
|
||||
"number": "{label} must be a number",
|
||||
"fqdn": "{label} must be a valid domain",
|
||||
"ip": "{label} must be a valid IP address",
|
||||
"fqdnip": "{label} must be a valid domain or IP address",
|
||||
"url": "{label} must be a valid URL"
|
||||
}
|
||||
},
|
||||
"stop-form": {
|
||||
"title": "Stop {name} ?",
|
||||
"subtitle": "All data will be retained",
|
||||
"submit": "Stop"
|
||||
},
|
||||
"uninstall-form": {
|
||||
"title": "Uninstall {name} ?",
|
||||
"subtitle": "All data for this app will be lost.",
|
||||
"warning": "Are you sure? This action cannot be undone.",
|
||||
"submit": "Uninstall"
|
||||
},
|
||||
"update-form": {
|
||||
"title": "Update {name} ?",
|
||||
"subtitle1": "Update app to latest verion :",
|
||||
"subtitle2": "This will reset your custom configuration (e.g. changes in docker-compose.yml)",
|
||||
"submit": "Update"
|
||||
},
|
||||
"update-settings-form": {
|
||||
"title": "Update {name} config"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"actions": {
|
||||
"tab-title": "Actions",
|
||||
"title": "Actions",
|
||||
"current-version": "Current version: {version}",
|
||||
"stay-up-to-date": "Stay up to date with the latest version of Tipi",
|
||||
"new-version": "A new version ({version}) of Tipi is available",
|
||||
"maintenance-title": "Maintenance",
|
||||
"maintenance-subtitle": "Common actions to perform on your instance",
|
||||
"restart": "Restart",
|
||||
"update": "Update to {version}",
|
||||
"already-latest": "Already up to date"
|
||||
},
|
||||
"settings": {
|
||||
"tab-title": "Settings",
|
||||
"title": "General settings",
|
||||
"subtitle": "This will update your settings.json file. Make sure you know what you are doing before updating these values.",
|
||||
"settings-updated": "Settings updated. Restart your instance to apply new settings.",
|
||||
"invalid-ip": "Invalid IP address",
|
||||
"invalid-url": "Invalid URL",
|
||||
"invalid-domain": "Invalid domain",
|
||||
"domain-name": "Domain name",
|
||||
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
|
||||
"dns-ip": "DNS IP",
|
||||
"internal-ip": "Internal IP",
|
||||
"internal-ip-hint": "IP address your server is listening on.",
|
||||
"apps-repo": "Apps repo URL",
|
||||
"apps-repo-hint": "URL to the apps repository.",
|
||||
"storage-path": "Storage path",
|
||||
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
|
||||
"local-domain": "Local domain",
|
||||
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
|
||||
"submit": "Save",
|
||||
"user-settings-title": "User settings",
|
||||
"language": "Language",
|
||||
"help-translate": "Help translate Tipi",
|
||||
"download-certificate": "Download certificate"
|
||||
},
|
||||
"security": {
|
||||
"tab-title": "Security",
|
||||
"change-password-title": "Change password",
|
||||
"change-password-subtitle": "Changing your password will log you out of all devices.",
|
||||
"password-change-success": "Password changed successfully",
|
||||
"2fa-title": "Two-factor authentication",
|
||||
"2fa-subtitle": "Two-factor authentication (2FA) adds an additional layer of security to your account.",
|
||||
"2fa-subtitle-2": "When enabled, you will be prompted to enter a code from your authenticator app when you log in.",
|
||||
"2fa-enable-success": "Two-factor authentication enabled",
|
||||
"2fa-disable-success": "Two-factor authentication disabled",
|
||||
"scan-qr-code": "Scan this QR code with your authenticator app.",
|
||||
"enter-key-manually": "Or enter this key manually.",
|
||||
"enter-2fa-code": "Enter the 6-digit code from your authenticator app",
|
||||
"enable-2fa": "Enable two-factor authentication",
|
||||
"disable-2fa": "Disable two-factor authentication",
|
||||
"password-needed": "Password needed",
|
||||
"password-needed-hint": "Your password is required to change two-factor authentication settings.",
|
||||
"form": {
|
||||
"password-length": "Password must be at least 8 characters",
|
||||
"password-match": "Passwords do not match",
|
||||
"current-password": "Current password",
|
||||
"new-password": "New password",
|
||||
"confirm-password": "Confirm new password",
|
||||
"change-password": "Change password",
|
||||
"password": "Password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"dashboard": "Dashboard",
|
||||
"my-apps": "My Apps",
|
||||
"app-store": "App Store",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout",
|
||||
"dark-mode": "Dark Mode",
|
||||
"light-mode": "Light Mode",
|
||||
"sponsor": "Sponsor",
|
||||
"source-code": "Source code",
|
||||
"update-available": "Update available"
|
||||
}
|
||||
}
|
|
@ -33,7 +33,8 @@
|
|||
"domain-already-in-use": "Domain {domain} is already in use by app {id}",
|
||||
"could-not-get-latest-version": "Could not get latest version",
|
||||
"current-version-is-latest": "Current version is already up to date",
|
||||
"major-version-update": "The major version has changed. Please update manually (instructions on GitHub)"
|
||||
"major-version-update": "The major version has changed. Please update manually (instructions on GitHub)",
|
||||
"demo-mode-limit": "Only 6 apps can be installed in the demo mode. Please uninstall an other app to install a new one."
|
||||
},
|
||||
"success": {}
|
||||
},
|
||||
|
|
299
src/client/messages/he-IL.json
Normal file
299
src/client/messages/he-IL.json
Normal file
|
@ -0,0 +1,299 @@
|
|||
{
|
||||
"server-messages": {
|
||||
"errors": {
|
||||
"invalid-credentials": "Invalid credentials",
|
||||
"admin-already-exists": "There is already an admin user. Please login to create a new user from the admin panel.",
|
||||
"missing-email-or-password": "Missing email or password",
|
||||
"invalid-username": "Invalid username",
|
||||
"user-already-exists": "User already exists",
|
||||
"error-creating-user": "Error creating user",
|
||||
"no-change-password-request": "No change password request found",
|
||||
"operator-not-found": "Operator user not found",
|
||||
"user-not-found": "User not found",
|
||||
"not-allowed-in-demo": "Not allowed in demo mode",
|
||||
"not-allowed-in-dev": "Not allowed in dev mode",
|
||||
"invalid-password": "Invalid password",
|
||||
"invalid-password-length": "Password must be at least 8 characters long",
|
||||
"invalid-locale": "Invalid locale",
|
||||
"totp-session-not-found": "2FA session not found",
|
||||
"totp-not-enabled": "2FA is not enabled for this user",
|
||||
"totp-invalid-code": "Invalid 2FA code",
|
||||
"totp-already-enabled": "2FA is already enabled for this user",
|
||||
"app-not-found": "App {id} not found",
|
||||
"app-failed-to-start": "Failed to start app {id}, see logs for more details",
|
||||
"app-failed-to-install": "Failed to install app {id}, see logs for more details",
|
||||
"app-failed-to-stop": "Failed to stop app {id}, see logs for more details",
|
||||
"app-failed-to-uninstall": "Failed to uninstall app {id}, see logs for more details",
|
||||
"app-failed-to-update": "Failed to update app {id}, see logs for more details",
|
||||
"domain-required-if-expose-app": "Domain is required if app is exposed",
|
||||
"domain-not-valid": "Domain {domain} is not a valid domain",
|
||||
"invalid-config": "App {id} has an invalid config.json file",
|
||||
"app-not-exposable": "App {id} is not exposable",
|
||||
"app-force-exposed": "App {id} works only with exposed domain",
|
||||
"domain-already-in-use": "Domain {domain} is already in use by app {id}",
|
||||
"could-not-get-latest-version": "Could not get latest version",
|
||||
"current-version-is-latest": "Current version is already up to date",
|
||||
"major-version-update": "The major version has changed. Please update manually (instructions on GitHub)"
|
||||
},
|
||||
"success": {}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Login to your account",
|
||||
"submit": "Login"
|
||||
},
|
||||
"totp": {
|
||||
"title": "Two-factor authentication",
|
||||
"instructions": "Enter the code from your authenticator app",
|
||||
"submit": "Confirm"
|
||||
},
|
||||
"register": {
|
||||
"title": "Register your account",
|
||||
"submit": "Register"
|
||||
},
|
||||
"reset-password": {
|
||||
"title": "Reset your password",
|
||||
"submit": "Reset password",
|
||||
"cancel": "Cancel password change request",
|
||||
"instructions": "Run this command on your server and then refresh this page",
|
||||
"success-title": "Password reset",
|
||||
"success": "Your password has been reset. You can now login with your new password. And your email {email}",
|
||||
"back-to-login": "Back to login"
|
||||
},
|
||||
"form": {
|
||||
"email": "Email address",
|
||||
"email-placeholder": "you@example.com",
|
||||
"password": "Password",
|
||||
"password-placeholder": "Enter your password",
|
||||
"password-confirmation": "Confirm password",
|
||||
"password-confirmation-placeholder": "Confirm your password",
|
||||
"forgot": "Forgot password?",
|
||||
"new-password-placeholder": "Your new password",
|
||||
"new-password-confirmation-placeholder": "Confirm your new password",
|
||||
"errors": {
|
||||
"email": {
|
||||
"required": "Email address is required",
|
||||
"email": "Email address is invalid",
|
||||
"invalid": "Email address is invalid"
|
||||
},
|
||||
"password": {
|
||||
"required": "Password is required",
|
||||
"minlength": "Password must be at least 8 characters"
|
||||
},
|
||||
"password-confirmation": {
|
||||
"required": "Password confirmation is required",
|
||||
"minlength": "Password confirmation must be at least 8 characters",
|
||||
"match": "Passwords do not match"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"cards": {
|
||||
"disk": {
|
||||
"title": "Disk Space",
|
||||
"subtitle": "Used out of {total} GB"
|
||||
},
|
||||
"memory": {
|
||||
"title": "Memory Used"
|
||||
},
|
||||
"cpu": {
|
||||
"title": "CPU Load",
|
||||
"subtitle": "Uninstall apps to reduce load"
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"status-running": "Running",
|
||||
"status-stopped": "Stopped",
|
||||
"status-starting": "Starting",
|
||||
"status-stopping": "Stopping",
|
||||
"status-updating": "Updating",
|
||||
"status-missing": "Missing",
|
||||
"status-installing": "Installing",
|
||||
"status-uninstalling": "Uninstalling",
|
||||
"update-available": "Update available",
|
||||
"my-apps": {
|
||||
"title": "My Apps",
|
||||
"empty-title": "No app installed",
|
||||
"empty-subtitle": "Install an app from the app store to get started",
|
||||
"empty-action": "Go to app store"
|
||||
},
|
||||
"app-store": {
|
||||
"title": "App Store",
|
||||
"search-placeholder": "Search apps",
|
||||
"category-placeholder": "Select a category",
|
||||
"no-results": "No app found",
|
||||
"no-results-subtitle": "Try to refine your search"
|
||||
},
|
||||
"app-details": {
|
||||
"install-success": "App installed successfully",
|
||||
"uninstall-success": "App uninstalled successfully",
|
||||
"stop-success": "App stopped successfully",
|
||||
"update-success": "App updated successfully",
|
||||
"start-success": "App started successfully",
|
||||
"update-config-success": "App config updated successfully. Restart the app to apply the changes",
|
||||
"version": "Version",
|
||||
"description": "Description",
|
||||
"base-info": "Base info",
|
||||
"source-code": "Source code",
|
||||
"author": "Author",
|
||||
"port": "Port",
|
||||
"categories-title": "Categories",
|
||||
"link": "Link",
|
||||
"website": "Website",
|
||||
"supported-arch": "Supported architectures",
|
||||
"choose-open-method": "Choose open method",
|
||||
"categories": {
|
||||
"data": "Data",
|
||||
"network": "Network",
|
||||
"media": "Media",
|
||||
"development": "Development",
|
||||
"automation": "Automation",
|
||||
"social": "Social",
|
||||
"utilities": "Utilities",
|
||||
"security": "Security",
|
||||
"photography": "Photography",
|
||||
"featured": "Featured",
|
||||
"books": "Books",
|
||||
"music": "Music",
|
||||
"finance": "Finance",
|
||||
"gaming": "Gaming",
|
||||
"ai": "AI"
|
||||
},
|
||||
"actions": {
|
||||
"start": "Start",
|
||||
"remove": "Remove",
|
||||
"settings": "Settings",
|
||||
"stop": "Stop",
|
||||
"open": "Open",
|
||||
"loading": "Loading",
|
||||
"cancel": "Cancel",
|
||||
"install": "Install",
|
||||
"update": "Update"
|
||||
},
|
||||
"install-form": {
|
||||
"title": "Install {name}",
|
||||
"expose-app": "Expose app",
|
||||
"domain-name": "Domain name",
|
||||
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
|
||||
"choose-option": "Choose an option...",
|
||||
"sumbit-install": "Install",
|
||||
"submit-update": "Update",
|
||||
"errors": {
|
||||
"required": "{label} is required",
|
||||
"regex": "{label} must match the pattern {pattern}",
|
||||
"max-length": "{label} must be less than {max} characters",
|
||||
"min-length": "{label} must be at least {min} characters",
|
||||
"between-length": "{label} must be between {min} and {max} characters",
|
||||
"invalid-email": "{label} must be a valid email address",
|
||||
"number": "{label} must be a number",
|
||||
"fqdn": "{label} must be a valid domain",
|
||||
"ip": "{label} must be a valid IP address",
|
||||
"fqdnip": "{label} must be a valid domain or IP address",
|
||||
"url": "{label} must be a valid URL"
|
||||
}
|
||||
},
|
||||
"stop-form": {
|
||||
"title": "Stop {name} ?",
|
||||
"subtitle": "All data will be retained",
|
||||
"submit": "Stop"
|
||||
},
|
||||
"uninstall-form": {
|
||||
"title": "Uninstall {name} ?",
|
||||
"subtitle": "All data for this app will be lost.",
|
||||
"warning": "Are you sure? This action cannot be undone.",
|
||||
"submit": "Uninstall"
|
||||
},
|
||||
"update-form": {
|
||||
"title": "Update {name} ?",
|
||||
"subtitle1": "Update app to latest verion :",
|
||||
"subtitle2": "This will reset your custom configuration (e.g. changes in docker-compose.yml)",
|
||||
"submit": "Update"
|
||||
},
|
||||
"update-settings-form": {
|
||||
"title": "Update {name} config"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"actions": {
|
||||
"tab-title": "Actions",
|
||||
"title": "Actions",
|
||||
"current-version": "Current version: {version}",
|
||||
"stay-up-to-date": "Stay up to date with the latest version of Tipi",
|
||||
"new-version": "A new version ({version}) of Tipi is available",
|
||||
"maintenance-title": "Maintenance",
|
||||
"maintenance-subtitle": "Common actions to perform on your instance",
|
||||
"restart": "Restart",
|
||||
"update": "Update to {version}",
|
||||
"already-latest": "Already up to date"
|
||||
},
|
||||
"settings": {
|
||||
"tab-title": "Settings",
|
||||
"title": "General settings",
|
||||
"subtitle": "This will update your settings.json file. Make sure you know what you are doing before updating these values.",
|
||||
"settings-updated": "Settings updated. Restart your instance to apply new settings.",
|
||||
"invalid-ip": "Invalid IP address",
|
||||
"invalid-url": "Invalid URL",
|
||||
"invalid-domain": "Invalid domain",
|
||||
"domain-name": "Domain name",
|
||||
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
|
||||
"dns-ip": "DNS IP",
|
||||
"internal-ip": "Internal IP",
|
||||
"internal-ip-hint": "IP address your server is listening on.",
|
||||
"apps-repo": "Apps repo URL",
|
||||
"apps-repo-hint": "URL to the apps repository.",
|
||||
"storage-path": "Storage path",
|
||||
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
|
||||
"local-domain": "Local domain",
|
||||
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
|
||||
"submit": "Save",
|
||||
"user-settings-title": "User settings",
|
||||
"language": "Language",
|
||||
"help-translate": "Help translate Tipi",
|
||||
"download-certificate": "Download certificate"
|
||||
},
|
||||
"security": {
|
||||
"tab-title": "Security",
|
||||
"change-password-title": "Change password",
|
||||
"change-password-subtitle": "Changing your password will log you out of all devices.",
|
||||
"password-change-success": "Password changed successfully",
|
||||
"2fa-title": "Two-factor authentication",
|
||||
"2fa-subtitle": "Two-factor authentication (2FA) adds an additional layer of security to your account.",
|
||||
"2fa-subtitle-2": "When enabled, you will be prompted to enter a code from your authenticator app when you log in.",
|
||||
"2fa-enable-success": "Two-factor authentication enabled",
|
||||
"2fa-disable-success": "Two-factor authentication disabled",
|
||||
"scan-qr-code": "Scan this QR code with your authenticator app.",
|
||||
"enter-key-manually": "Or enter this key manually.",
|
||||
"enter-2fa-code": "Enter the 6-digit code from your authenticator app",
|
||||
"enable-2fa": "Enable two-factor authentication",
|
||||
"disable-2fa": "Disable two-factor authentication",
|
||||
"password-needed": "Password needed",
|
||||
"password-needed-hint": "Your password is required to change two-factor authentication settings.",
|
||||
"form": {
|
||||
"password-length": "Password must be at least 8 characters",
|
||||
"password-match": "Passwords do not match",
|
||||
"current-password": "Current password",
|
||||
"new-password": "New password",
|
||||
"confirm-password": "Confirm new password",
|
||||
"change-password": "Change password",
|
||||
"password": "Password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"dashboard": "Dashboard",
|
||||
"my-apps": "My Apps",
|
||||
"app-store": "App Store",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout",
|
||||
"dark-mode": "Dark Mode",
|
||||
"light-mode": "Light Mode",
|
||||
"sponsor": "Sponsor",
|
||||
"source-code": "Source code",
|
||||
"update-available": "Update available"
|
||||
}
|
||||
}
|
|
@ -1,89 +1,89 @@
|
|||
{
|
||||
"server-messages": {
|
||||
"errors": {
|
||||
"invalid-credentials": "Invalid credentials",
|
||||
"admin-already-exists": "There is already an admin user. Please login to create a new user from the admin panel.",
|
||||
"missing-email-or-password": "Missing email or password",
|
||||
"invalid-username": "Invalid username",
|
||||
"user-already-exists": "User already exists",
|
||||
"error-creating-user": "Error creating user",
|
||||
"no-change-password-request": "No change password request found",
|
||||
"operator-not-found": "Operator user not found",
|
||||
"user-not-found": "User not found",
|
||||
"not-allowed-in-demo": "Not allowed in demo mode",
|
||||
"not-allowed-in-dev": "Not allowed in dev mode",
|
||||
"invalid-password": "Invalid password",
|
||||
"invalid-password-length": "Password must be at least 8 characters long",
|
||||
"invalid-locale": "Invalid locale",
|
||||
"totp-session-not-found": "2FA session not found",
|
||||
"totp-not-enabled": "2FA is not enabled for this user",
|
||||
"totp-invalid-code": "Invalid 2FA code",
|
||||
"totp-already-enabled": "2FA is already enabled for this user",
|
||||
"app-not-found": "App {id} not found",
|
||||
"app-failed-to-start": "Failed to start app {id}, see logs for more details",
|
||||
"app-failed-to-install": "Failed to install app {id}, see logs for more details",
|
||||
"app-failed-to-stop": "Failed to stop app {id}, see logs for more details",
|
||||
"app-failed-to-uninstall": "Failed to uninstall app {id}, see logs for more details",
|
||||
"app-failed-to-update": "Failed to update app {id}, see logs for more details",
|
||||
"domain-required-if-expose-app": "Domain is required if app is exposed",
|
||||
"domain-not-valid": "Domain {domain} is not a valid domain",
|
||||
"invalid-config": "App {id} has an invalid config.json file",
|
||||
"app-not-exposable": "App {id} is not exposable",
|
||||
"app-force-exposed": "App {id} works only with exposed domain",
|
||||
"domain-already-in-use": "Domain {domain} is already in use by app {id}",
|
||||
"could-not-get-latest-version": "Could not get latest version",
|
||||
"current-version-is-latest": "Current version is already up to date",
|
||||
"major-version-update": "The major version has changed. Please update manually (instructions on GitHub)"
|
||||
"invalid-credentials": "Credenziali invalide",
|
||||
"admin-already-exists": "Esiste già un utente amministratore. Accedi per creare un nuovo utente dal pannello di amministrazione.",
|
||||
"missing-email-or-password": "Email o password mancanti",
|
||||
"invalid-username": "Nome utente non valido",
|
||||
"user-already-exists": "Utente già esistente",
|
||||
"error-creating-user": "Errore durante la creazione dell'utente",
|
||||
"no-change-password-request": "Nessuna richiesta di cambio password trovata",
|
||||
"operator-not-found": "Utente operatore non trovato",
|
||||
"user-not-found": "Utente non trovato",
|
||||
"not-allowed-in-demo": "Non consentito in modalità demo",
|
||||
"not-allowed-in-dev": "Non consentito in modalità sviluppo",
|
||||
"invalid-password": "Password non valida",
|
||||
"invalid-password-length": "La password deve essere lunga almeno 8 caratteri",
|
||||
"invalid-locale": "Locale non valido",
|
||||
"totp-session-not-found": "Sessione 2FA non trovata",
|
||||
"totp-not-enabled": "2FA non abilitato per questo utente",
|
||||
"totp-invalid-code": "Codice 2FA invalido",
|
||||
"totp-already-enabled": "2FA già abilitata per questo utente",
|
||||
"app-not-found": "App {id} non trovata",
|
||||
"app-failed-to-start": "Non possibile avviare l'app {id}, guarda i log per maggiori dettagli",
|
||||
"app-failed-to-install": "Non possibile installare l'app {id}, guarda i log per maggiori dettagli",
|
||||
"app-failed-to-stop": "Non possibile arrestare l'app {id}, guarda i log per maggiori dettagli",
|
||||
"app-failed-to-uninstall": "Non possibile disinstallare l'app {id}, guarda i log per maggiori dettagli",
|
||||
"app-failed-to-update": "Non possibile aggiornare l'app {id}, guarda i log per maggiori dettagli",
|
||||
"domain-required-if-expose-app": "Dominio richiesto quando l'app è esposta",
|
||||
"domain-not-valid": "Dominio {domain} non è un dominio valido",
|
||||
"invalid-config": "L'app {id} ha un file config.json non valido",
|
||||
"app-not-exposable": "App {id} non può essere esposta",
|
||||
"app-force-exposed": "L'app {id} può funzionare solo con un dominio esposto",
|
||||
"domain-already-in-use": "Il dominio {domain} è già in uso dall'app {id}",
|
||||
"could-not-get-latest-version": "Impossibile ottenere l'ultima versione",
|
||||
"current-version-is-latest": "La versione corrente è già la più recente",
|
||||
"major-version-update": "La version major è cambiata. Aggiorna manualmente (istruzioni su GitHub)"
|
||||
},
|
||||
"success": {}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Login to your account",
|
||||
"submit": "Login"
|
||||
"title": "Accedi al tuo account",
|
||||
"submit": "Accedi"
|
||||
},
|
||||
"totp": {
|
||||
"title": "Two-factor authentication",
|
||||
"instructions": "Enter the code from your authenticator app",
|
||||
"submit": "Confirm"
|
||||
"title": "Autenticazione a due fattori",
|
||||
"instructions": "Inserisci il codice fornito dalla tua app di autenticazione",
|
||||
"submit": "Verifica"
|
||||
},
|
||||
"register": {
|
||||
"title": "Register your account",
|
||||
"submit": "Register"
|
||||
"title": "Crea un nuovo account",
|
||||
"submit": "Registrati"
|
||||
},
|
||||
"reset-password": {
|
||||
"title": "Reset your password",
|
||||
"submit": "Reset password",
|
||||
"cancel": "Cancel password change request",
|
||||
"instructions": "Run this command on your server and then refresh this page",
|
||||
"success-title": "Password reset",
|
||||
"success": "Your password has been reset. You can now login with your new password. And your email {email}",
|
||||
"back-to-login": "Back to login"
|
||||
"title": "Ripristina la tua password",
|
||||
"submit": "Resetta password",
|
||||
"cancel": "Annulla richiesta di cambio password",
|
||||
"instructions": "Esegui questo comando sul tuo server e poi aggiorna questa pagina",
|
||||
"success-title": "Resettata la password",
|
||||
"success": "La tua password è stata resettata. Ora puoi accedere con la tua nuova password. E la tua email {email}",
|
||||
"back-to-login": "Torna alla pagina di login"
|
||||
},
|
||||
"form": {
|
||||
"email": "Email address",
|
||||
"email-placeholder": "you@example.com",
|
||||
"email": "Indirizzo email",
|
||||
"email-placeholder": "mario.rossi@esempio.it",
|
||||
"password": "Password",
|
||||
"password-placeholder": "Enter your password",
|
||||
"password-confirmation": "Confirm password",
|
||||
"password-confirmation-placeholder": "Confirm your password",
|
||||
"forgot": "Forgot password?",
|
||||
"new-password-placeholder": "Your new password",
|
||||
"new-password-confirmation-placeholder": "Confirm your new password",
|
||||
"password-placeholder": "Inserisci password",
|
||||
"password-confirmation": "Conferma password",
|
||||
"password-confirmation-placeholder": "Conferma la tua password",
|
||||
"forgot": "Password dimenticata?",
|
||||
"new-password-placeholder": "La tua nuova password",
|
||||
"new-password-confirmation-placeholder": "Conferma la tua nuova password",
|
||||
"errors": {
|
||||
"email": {
|
||||
"required": "Email address is required",
|
||||
"email": "Email address is invalid",
|
||||
"invalid": "Email address is invalid"
|
||||
"required": "Indirizzo email obbligatorio",
|
||||
"email": "Indirizzo email invalido",
|
||||
"invalid": "Indirizzo email invalido"
|
||||
},
|
||||
"password": {
|
||||
"required": "Password is required",
|
||||
"minlength": "Password must be at least 8 characters"
|
||||
"required": "Password obbligatoria",
|
||||
"minlength": "La password deve essere lunga almeno 8 caratteri"
|
||||
},
|
||||
"password-confirmation": {
|
||||
"required": "Password confirmation is required",
|
||||
"minlength": "Password confirmation must be at least 8 characters",
|
||||
"match": "Passwords do not match"
|
||||
"required": "La password di conferma è obbligatoria",
|
||||
"minlength": "La password di conferma deve essere lunga almeno 8 caratteri",
|
||||
"match": "La password di conferma non corrisponde alla password"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -92,208 +92,208 @@
|
|||
"title": "Dashboard",
|
||||
"cards": {
|
||||
"disk": {
|
||||
"title": "Disk Space",
|
||||
"subtitle": "Used out of {total} GB"
|
||||
"title": "Spazio su disco",
|
||||
"subtitle": "Utilizzati su un totale di {total} GB"
|
||||
},
|
||||
"memory": {
|
||||
"title": "Memory Used"
|
||||
"title": "Memoria in uso"
|
||||
},
|
||||
"cpu": {
|
||||
"title": "CPU Load",
|
||||
"subtitle": "Uninstall apps to reduce load"
|
||||
"title": "Carico sulla CPU",
|
||||
"subtitle": "Disinstalla alcune app per ridurre il carico sulla CPU"
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"status-running": "Running",
|
||||
"status-stopped": "Stopped",
|
||||
"status-starting": "Starting",
|
||||
"status-stopping": "Stopping",
|
||||
"status-updating": "Updating",
|
||||
"status-missing": "Missing",
|
||||
"status-installing": "Installing",
|
||||
"status-uninstalling": "Uninstalling",
|
||||
"update-available": "Update available",
|
||||
"status-running": "In esecuzione",
|
||||
"status-stopped": "Arrestato",
|
||||
"status-starting": "In avvio",
|
||||
"status-stopping": "In arresto",
|
||||
"status-updating": "In aggiornamento",
|
||||
"status-missing": "Mancante",
|
||||
"status-installing": "In installazione",
|
||||
"status-uninstalling": "In disinstallazione",
|
||||
"update-available": "Aggiornamento disponibile",
|
||||
"my-apps": {
|
||||
"title": "My Apps",
|
||||
"empty-title": "No app installed",
|
||||
"empty-subtitle": "Install an app from the app store to get started",
|
||||
"empty-action": "Go to app store"
|
||||
"title": "Le mie Apps",
|
||||
"empty-title": "Nessuna app installata",
|
||||
"empty-subtitle": "Installa un app dall'app store per iniziare",
|
||||
"empty-action": "Vai all'app store"
|
||||
},
|
||||
"app-store": {
|
||||
"title": "App Store",
|
||||
"search-placeholder": "Search apps",
|
||||
"category-placeholder": "Select a category",
|
||||
"no-results": "No app found",
|
||||
"no-results-subtitle": "Try to refine your search"
|
||||
"search-placeholder": "Cerca app",
|
||||
"category-placeholder": "Seleziona una categoria",
|
||||
"no-results": "Nessuna app trovata",
|
||||
"no-results-subtitle": "Prova a ottimizare la tua ricerca"
|
||||
},
|
||||
"app-details": {
|
||||
"install-success": "App installed successfully",
|
||||
"uninstall-success": "App uninstalled successfully",
|
||||
"stop-success": "App stopped successfully",
|
||||
"update-success": "App updated successfully",
|
||||
"start-success": "App started successfully",
|
||||
"update-config-success": "App config updated successfully. Restart the app to apply the changes",
|
||||
"version": "Version",
|
||||
"description": "Description",
|
||||
"base-info": "Base info",
|
||||
"source-code": "Source code",
|
||||
"author": "Author",
|
||||
"port": "Port",
|
||||
"categories-title": "Categories",
|
||||
"install-success": "App installata con successo",
|
||||
"uninstall-success": "App disinstallata con successo",
|
||||
"stop-success": "App arrestata con successo",
|
||||
"update-success": "App aggiornata con successo",
|
||||
"start-success": "App avviata con successo",
|
||||
"update-config-success": "App configurata con successo. Riavvia l'app per applicare le modifiche",
|
||||
"version": "Versione",
|
||||
"description": "Descrizione",
|
||||
"base-info": "Info di base",
|
||||
"source-code": "Codice sorgente",
|
||||
"author": "Autore",
|
||||
"port": "Porta",
|
||||
"categories-title": "Categorie",
|
||||
"link": "Link",
|
||||
"website": "Website",
|
||||
"supported-arch": "Supported architectures",
|
||||
"choose-open-method": "Choose open method",
|
||||
"website": "Sito web",
|
||||
"supported-arch": "Architetture supportate",
|
||||
"choose-open-method": "Scegli un metodo di apertura",
|
||||
"categories": {
|
||||
"data": "Data",
|
||||
"network": "Network",
|
||||
"network": "Rete",
|
||||
"media": "Media",
|
||||
"development": "Development",
|
||||
"automation": "Automation",
|
||||
"development": "Sviluppo",
|
||||
"automation": "Automazione",
|
||||
"social": "Social",
|
||||
"utilities": "Utilities",
|
||||
"security": "Security",
|
||||
"photography": "Photography",
|
||||
"featured": "Featured",
|
||||
"books": "Books",
|
||||
"music": "Music",
|
||||
"finance": "Finance",
|
||||
"gaming": "Gaming",
|
||||
"utilities": "Utilità",
|
||||
"security": "Sicurezza",
|
||||
"photography": "Fotografia",
|
||||
"featured": "In evidenza",
|
||||
"books": "Libri",
|
||||
"music": "Musica",
|
||||
"finance": "Finanza",
|
||||
"gaming": "Videogiochi",
|
||||
"ai": "AI"
|
||||
},
|
||||
"actions": {
|
||||
"start": "Start",
|
||||
"remove": "Remove",
|
||||
"settings": "Settings",
|
||||
"stop": "Stop",
|
||||
"open": "Open",
|
||||
"loading": "Loading",
|
||||
"cancel": "Cancel",
|
||||
"install": "Install",
|
||||
"update": "Update"
|
||||
"start": "Avvia",
|
||||
"remove": "Rimuovi",
|
||||
"settings": "Impostazioni",
|
||||
"stop": "Arresta",
|
||||
"open": "Apri",
|
||||
"loading": "Caricamento",
|
||||
"cancel": "Cancella",
|
||||
"install": "Installa",
|
||||
"update": "Aggiorna"
|
||||
},
|
||||
"install-form": {
|
||||
"title": "Install {name}",
|
||||
"expose-app": "Expose app",
|
||||
"domain-name": "Domain name",
|
||||
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
|
||||
"choose-option": "Choose an option...",
|
||||
"sumbit-install": "Install",
|
||||
"submit-update": "Update",
|
||||
"title": "Installa {name}",
|
||||
"expose-app": "Esponi app",
|
||||
"domain-name": "Nome dominio",
|
||||
"domain-name-hint": "Assicurati che questo dominio contenga un record di tipo A che punti al tuo IP.",
|
||||
"choose-option": "Scegli un opzione...",
|
||||
"sumbit-install": "Installa",
|
||||
"submit-update": "Aggiorna",
|
||||
"errors": {
|
||||
"required": "{label} is required",
|
||||
"regex": "{label} must match the pattern {pattern}",
|
||||
"max-length": "{label} must be less than {max} characters",
|
||||
"min-length": "{label} must be at least {min} characters",
|
||||
"between-length": "{label} must be between {min} and {max} characters",
|
||||
"invalid-email": "{label} must be a valid email address",
|
||||
"number": "{label} must be a number",
|
||||
"fqdn": "{label} must be a valid domain",
|
||||
"ip": "{label} must be a valid IP address",
|
||||
"fqdnip": "{label} must be a valid domain or IP address",
|
||||
"url": "{label} must be a valid URL"
|
||||
"required": "{label} è obbligatorio",
|
||||
"regex": "{label} deve rispettare il pattern {pattern}",
|
||||
"max-length": "{label} deve contenere meno di {max} caratteri",
|
||||
"min-length": "{label} deve contenere almeno {min} caratteri",
|
||||
"between-length": "{label} deve contenere tra {min} e {max} caratteri",
|
||||
"invalid-email": "{label} deve essere un indirizzo email valido",
|
||||
"number": "{label} deve essere un numero",
|
||||
"fqdn": "{label} deve essere un dominio valido",
|
||||
"ip": "{label} deve essere un indirizzo IP valido",
|
||||
"fqdnip": "{label} deve essere un indirizzo IP o un dominio valido",
|
||||
"url": "{label} deve essere un URL valido"
|
||||
}
|
||||
},
|
||||
"stop-form": {
|
||||
"title": "Stop {name} ?",
|
||||
"subtitle": "All data will be retained",
|
||||
"submit": "Stop"
|
||||
"title": "Arrestare {name} ?",
|
||||
"subtitle": "Tutti i dati verranno mantenuti",
|
||||
"submit": "Arresta"
|
||||
},
|
||||
"uninstall-form": {
|
||||
"title": "Uninstall {name} ?",
|
||||
"subtitle": "All data for this app will be lost.",
|
||||
"warning": "Are you sure? This action cannot be undone.",
|
||||
"submit": "Uninstall"
|
||||
"title": "Disinstallare {name} ?",
|
||||
"subtitle": "Tutti i dati relativi a questa app verranno persi",
|
||||
"warning": "Sei sicuro? Questa azione è irreversibile.",
|
||||
"submit": "Disinstalla"
|
||||
},
|
||||
"update-form": {
|
||||
"title": "Update {name} ?",
|
||||
"subtitle1": "Update app to latest verion :",
|
||||
"subtitle2": "This will reset your custom configuration (e.g. changes in docker-compose.yml)",
|
||||
"submit": "Update"
|
||||
"title": "Aggiornare {name} ?",
|
||||
"subtitle1": "Aggiorna app all'ultima versione :",
|
||||
"subtitle2": "Questo resetterà la tua configurazione personalizzata (es. le modifiche in docker-compose.yml)",
|
||||
"submit": "Aggiorna"
|
||||
},
|
||||
"update-settings-form": {
|
||||
"title": "Update {name} config"
|
||||
"title": "Aggiorna configurazione {name}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"title": "Impostazioni",
|
||||
"actions": {
|
||||
"tab-title": "Actions",
|
||||
"title": "Actions",
|
||||
"current-version": "Current version: {version}",
|
||||
"stay-up-to-date": "Stay up to date with the latest version of Tipi",
|
||||
"new-version": "A new version ({version}) of Tipi is available",
|
||||
"maintenance-title": "Maintenance",
|
||||
"maintenance-subtitle": "Common actions to perform on your instance",
|
||||
"restart": "Restart",
|
||||
"update": "Update to {version}",
|
||||
"already-latest": "Already up to date"
|
||||
"tab-title": "Azioni",
|
||||
"title": "Azioni",
|
||||
"current-version": "Versione corrente: {version}",
|
||||
"stay-up-to-date": "Rimani aggiornato all'ultima versione di Tipi",
|
||||
"new-version": "Una nuova versione ({version}) di Tipi è disponibile",
|
||||
"maintenance-title": "Manutenzione",
|
||||
"maintenance-subtitle": "Azioni comuni da eseguire sulla tua istanza",
|
||||
"restart": "Riavvia",
|
||||
"update": "Aggiorna alla {version}",
|
||||
"already-latest": "Già aggiornato"
|
||||
},
|
||||
"settings": {
|
||||
"tab-title": "Settings",
|
||||
"title": "General settings",
|
||||
"subtitle": "This will update your settings.json file. Make sure you know what you are doing before updating these values.",
|
||||
"settings-updated": "Settings updated. Restart your instance to apply new settings.",
|
||||
"invalid-ip": "Invalid IP address",
|
||||
"invalid-url": "Invalid URL",
|
||||
"invalid-domain": "Invalid domain",
|
||||
"domain-name": "Domain name",
|
||||
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
|
||||
"tab-title": "Impostazioni",
|
||||
"title": "Impostazioni generali",
|
||||
"subtitle": "Questo aggiornerà il tuo file settings.json. Assicurati di sapere cosa stai facendo prima di aggiornare questi valori.",
|
||||
"settings-updated": "Impostazioni aggiornate. Riavvia la tua istanza per applicare le nuove impostazioni.",
|
||||
"invalid-ip": "Indirizzo IP invalido",
|
||||
"invalid-url": "URL invalido",
|
||||
"invalid-domain": "Nome di dominio invalido",
|
||||
"domain-name": "Nome di dominio",
|
||||
"domain-name-hint": "Assicurati che questo dominio contenga un record di tipo A che punti al tuo indirizzo IP.",
|
||||
"dns-ip": "DNS IP",
|
||||
"internal-ip": "Internal IP",
|
||||
"internal-ip-hint": "IP address your server is listening on.",
|
||||
"apps-repo": "Apps repo URL",
|
||||
"apps-repo-hint": "URL to the apps repository.",
|
||||
"storage-path": "Storage path",
|
||||
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
|
||||
"local-domain": "Local domain",
|
||||
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
|
||||
"submit": "Save",
|
||||
"user-settings-title": "User settings",
|
||||
"language": "Language",
|
||||
"help-translate": "Help translate Tipi",
|
||||
"download-certificate": "Download certificate"
|
||||
"internal-ip": "Indirizzo IP interno",
|
||||
"internal-ip-hint": "Indirizzo IP sul quale il tuo server è in ascolto.",
|
||||
"apps-repo": "URL del registro delle apps",
|
||||
"apps-repo-hint": "URL del registro delle apps.",
|
||||
"storage-path": "Percorso di archiviazione",
|
||||
"storage-path-hint": "Percorso alla directory di archiviazione. Lascia vuoto per usare l'impostazione predefinita (runtipi/app-data). Assicurati che sia un percorso assoluto e che esista",
|
||||
"local-domain": "Nome di dominio locale",
|
||||
"local-domain-hint": "Nome di dominio utilizzato per accedere alle app nella tua rete locale. Le tue app saranno accessibili all'indirizzo nome-app.nome-di-dominio-locale.",
|
||||
"submit": "Salva",
|
||||
"user-settings-title": "Impostazioni utente",
|
||||
"language": "Lingua",
|
||||
"help-translate": "Aiutaci a tradurre Tipi",
|
||||
"download-certificate": "Scarica certificato"
|
||||
},
|
||||
"security": {
|
||||
"tab-title": "Security",
|
||||
"change-password-title": "Change password",
|
||||
"change-password-subtitle": "Changing your password will log you out of all devices.",
|
||||
"password-change-success": "Password changed successfully",
|
||||
"2fa-title": "Two-factor authentication",
|
||||
"2fa-subtitle": "Two-factor authentication (2FA) adds an additional layer of security to your account.",
|
||||
"2fa-subtitle-2": "When enabled, you will be prompted to enter a code from your authenticator app when you log in.",
|
||||
"2fa-enable-success": "Two-factor authentication enabled",
|
||||
"2fa-disable-success": "Two-factor authentication disabled",
|
||||
"scan-qr-code": "Scan this QR code with your authenticator app.",
|
||||
"enter-key-manually": "Or enter this key manually.",
|
||||
"enter-2fa-code": "Enter the 6-digit code from your authenticator app",
|
||||
"enable-2fa": "Enable two-factor authentication",
|
||||
"disable-2fa": "Disable two-factor authentication",
|
||||
"password-needed": "Password needed",
|
||||
"password-needed-hint": "Your password is required to change two-factor authentication settings.",
|
||||
"tab-title": "Sicurezza",
|
||||
"change-password-title": "Cambia password",
|
||||
"change-password-subtitle": "Cambiare la tua password ti disconnetterà da tutti i dispositivi.",
|
||||
"password-change-success": "Password aggiornata con successo",
|
||||
"2fa-title": "Autenticazione a due fattori",
|
||||
"2fa-subtitle": "L'autenticazione a due fattori (2FA) aggiunge un ulteriore livello di sicurezza al tuo account.",
|
||||
"2fa-subtitle-2": "Quando abilitata, ti verrà chiesto di inserire un codice dalla tua app di autenticazione quando accedi.",
|
||||
"2fa-enable-success": "Autenticazione a due fattori abilitata",
|
||||
"2fa-disable-success": "Autenticazione a due fattori disabilitata",
|
||||
"scan-qr-code": "Scansiona questo codice QR con la tua app di autenticazione.",
|
||||
"enter-key-manually": "Inserisci manualmente la chiave.",
|
||||
"enter-2fa-code": "Inserisci il codice a 6 cifre fornito dalla tua app di autenticazione",
|
||||
"enable-2fa": "Abilita autenticazione a due fattori",
|
||||
"disable-2fa": "Disabilita autenticazione a due fattori",
|
||||
"password-needed": "Password richiesta",
|
||||
"password-needed-hint": "La tua password è richiesta per cambiare le impostazioni relative all'autenticazione a due fattori.",
|
||||
"form": {
|
||||
"password-length": "Password must be at least 8 characters",
|
||||
"password-match": "Passwords do not match",
|
||||
"current-password": "Current password",
|
||||
"new-password": "New password",
|
||||
"confirm-password": "Confirm new password",
|
||||
"change-password": "Change password",
|
||||
"password-length": "La password deve essere lunga almeno 8 caratteri",
|
||||
"password-match": "Le password non corrispondono",
|
||||
"current-password": "Password attuale",
|
||||
"new-password": "Nuova password",
|
||||
"confirm-password": "Conferma nuova password",
|
||||
"change-password": "Cambia password",
|
||||
"password": "Password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"dashboard": "Dashboard",
|
||||
"my-apps": "My Apps",
|
||||
"my-apps": "Le mie App",
|
||||
"app-store": "App Store",
|
||||
"settings": "Settings",
|
||||
"settings": "Impostazioni",
|
||||
"logout": "Logout",
|
||||
"dark-mode": "Dark Mode",
|
||||
"light-mode": "Light Mode",
|
||||
"sponsor": "Sponsor",
|
||||
"source-code": "Source code",
|
||||
"update-available": "Update available"
|
||||
"source-code": "Codice sorgente",
|
||||
"update-available": "Aggiornamento disponibile"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
"submit": "Onayla"
|
||||
},
|
||||
"register": {
|
||||
"title": "Hesabınızı kaydedin",
|
||||
"title": "Yeni hesabınızı kaydedin",
|
||||
"submit": "Kayıt Ol"
|
||||
},
|
||||
"reset-password": {
|
||||
|
@ -63,7 +63,7 @@
|
|||
"form": {
|
||||
"email": "E-posta adresi",
|
||||
"email-placeholder": "siz@ornek.com",
|
||||
"password": "Şifre",
|
||||
"password": "Parola",
|
||||
"password-placeholder": "Parolanızı girin",
|
||||
"password-confirmation": "Parolanızı tekrar yazınız",
|
||||
"password-confirmation-placeholder": "Parolanızı onaylayın",
|
||||
|
@ -96,10 +96,10 @@
|
|||
"subtitle": "{total} GB'tan kullanıldı"
|
||||
},
|
||||
"memory": {
|
||||
"title": "Bellek Kullanımı"
|
||||
"title": "Bellek kullanımı"
|
||||
},
|
||||
"cpu": {
|
||||
"title": "CPU Yükü",
|
||||
"title": "CPU kullanımı",
|
||||
"subtitle": "Yükü azaltmak için uygulamaları kaldırın"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ const randomCategory = (): AppCategory[] => {
|
|||
return [categories[randomIndex] as AppCategory];
|
||||
};
|
||||
|
||||
export const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
|
||||
const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
|
||||
const name = faker.lorem.word();
|
||||
return {
|
||||
id: name.toLowerCase(),
|
||||
|
@ -62,5 +62,3 @@ export const createAppEntity = (params: CreateAppEntityParams): AppWithInfo => {
|
|||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
export const createAppsRandomly = (count: number): AppInfo[] => Array.from({ length: count }).map(() => createApp());
|
||||
|
|
|
@ -2,14 +2,12 @@ import { rest } from 'msw';
|
|||
import SuperJSON from 'superjson';
|
||||
import type { RouterInput, RouterOutput } from '../../server/routers/_app';
|
||||
|
||||
export type RpcResponse<Data> = RpcSuccessResponse<Data> | RpcErrorResponse;
|
||||
|
||||
export type RpcSuccessResponse<Data> = {
|
||||
type RpcSuccessResponse<Data> = {
|
||||
id: null;
|
||||
result: { type: 'data'; data: Data };
|
||||
};
|
||||
|
||||
export type RpcErrorResponse = {
|
||||
type RpcErrorResponse = {
|
||||
error: {
|
||||
json: {
|
||||
message: string;
|
||||
|
|
|
@ -95,7 +95,7 @@ export const SettingsForm = (props: IProps) => {
|
|||
|
||||
const downloadCertificate = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
window.open('/certificate');
|
||||
window.open('/api/certificate');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -60,7 +60,7 @@ export const GeneralActions = () => {
|
|||
return (
|
||||
<div>
|
||||
{versionQuery.data?.body && (
|
||||
<div className="mt-3 card col-4">
|
||||
<div className="mt-3 card col-12 col-md-8">
|
||||
<div className="card-stamp">
|
||||
<div className="card-stamp-icon bg-yellow">
|
||||
<IconStar size={80} />
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
import merge from 'lodash.merge';
|
||||
import { deleteCookie, setCookie } from 'cookies-next';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import { TipiCache } from '@/server/core/TipiCache';
|
||||
import { getAuthedPageProps, getMessagesPageProps } from '../page-helpers';
|
||||
import englishMessages from '../../messages/en.json';
|
||||
import frenchMessages from '../../messages/fr-FR.json';
|
||||
|
||||
const cache = new TipiCache();
|
||||
|
||||
afterAll(async () => {
|
||||
await cache.close();
|
||||
});
|
||||
|
||||
describe('test: getAuthedPageProps()', () => {
|
||||
it('should redirect to /login if there is no user id in session', async () => {
|
||||
// arrange
|
||||
const ctx = { req: { session: {} } };
|
||||
const ctx = { req: { headers: {} } };
|
||||
|
||||
// act
|
||||
// @ts-expect-error - we're passing in a partial context
|
||||
|
@ -21,7 +28,8 @@ describe('test: getAuthedPageProps()', () => {
|
|||
|
||||
it('should return props if there is a user id in session', async () => {
|
||||
// arrange
|
||||
const ctx = { req: { session: { userId: '123' } } };
|
||||
const ctx = { req: { headers: { 'x-session-id': '123' } } };
|
||||
await cache.set('session:123', '456');
|
||||
|
||||
// act
|
||||
// @ts-expect-error - we're passing in a partial context
|
||||
|
|
|
@ -2,9 +2,13 @@ import { GetServerSideProps } from 'next';
|
|||
import merge from 'lodash.merge';
|
||||
import { getLocaleFromString } from '@/shared/internationalization/locales';
|
||||
import { getCookie } from 'cookies-next';
|
||||
import { TipiCache } from '@/server/core/TipiCache';
|
||||
|
||||
export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
|
||||
const { userId } = ctx.req.session;
|
||||
const cache = new TipiCache();
|
||||
const sessionId = ctx.req.headers['x-session-id'];
|
||||
const userId = await cache.get(`session:${sessionId}`);
|
||||
await cache.close();
|
||||
|
||||
if (!userId) {
|
||||
return {
|
||||
|
@ -21,11 +25,10 @@ export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
|
|||
};
|
||||
|
||||
export const getMessagesPageProps: GetServerSideProps = async (ctx) => {
|
||||
const { locale: sessionLocale } = ctx.req.session;
|
||||
const cookieLocale = getCookie('tipi-locale', { req: ctx.req });
|
||||
const browserLocale = ctx.req.headers['accept-language']?.split(',')[0];
|
||||
|
||||
const locale = getLocaleFromString(String(sessionLocale || cookieLocale || browserLocale || 'en'));
|
||||
const locale = getLocaleFromString(String(cookieLocale || browserLocale || 'en'));
|
||||
|
||||
const englishMessages = (await import(`../messages/en.json`)).default;
|
||||
const messages = (await import(`../messages/${locale}.json`)).default;
|
||||
|
|
33
src/middleware.ts
Normal file
33
src/middleware.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Middleware to set session ID in request headers
|
||||
* @param {NextRequest} request - Request object
|
||||
*/
|
||||
export async function middleware(request: NextRequest) {
|
||||
let sessionId = '';
|
||||
|
||||
const cookie = request.cookies.get('tipi.sid')?.value;
|
||||
|
||||
// Check if session ID exists in cookies
|
||||
if (cookie) {
|
||||
sessionId = cookie;
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
|
||||
if (sessionId) {
|
||||
requestHeaders.set('x-session-id', sessionId);
|
||||
}
|
||||
|
||||
const response = NextResponse.next({
|
||||
request: { headers: requestHeaders },
|
||||
});
|
||||
|
||||
if (sessionId) {
|
||||
response.headers.set('x-session-id', sessionId);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
27
src/pages/api/app-image.ts
Normal file
27
src/pages/api/app-image.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import fs from 'fs';
|
||||
import { getConfig } from '@/server/core/TipiConfig/TipiConfig';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* API endpoint to get the image of an app
|
||||
*
|
||||
* @param {NextApiRequest} req - The request
|
||||
* @param {NextApiResponse} res - The response
|
||||
*/
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (typeof req.query.id !== 'string') {
|
||||
res.status(404).send('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = path.join(getConfig().rootFolder, 'repos', getConfig().appsRepoId, 'apps', req.query.id, 'metadata', 'logo.jpg');
|
||||
const file = fs.readFileSync(filePath);
|
||||
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.send(file);
|
||||
} catch (e) {
|
||||
res.status(404).send('Not found');
|
||||
}
|
||||
}
|
41
src/pages/api/certificate.ts
Normal file
41
src/pages/api/certificate.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { getConfig } from '@/server/core/TipiConfig/TipiConfig';
|
||||
import { TipiCache } from '@/server/core/TipiCache/TipiCache';
|
||||
import { AuthQueries } from '@/server/queries/auth/auth.queries';
|
||||
import { db } from '@/server/db';
|
||||
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
/**
|
||||
* API endpoint to get the self-signed certificate
|
||||
*
|
||||
* @param {NextApiRequest} req - The request
|
||||
* @param {NextApiResponse} res - The response
|
||||
*/
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const cache = new TipiCache();
|
||||
|
||||
const authService = new AuthQueries(db);
|
||||
|
||||
const sessionId = req.headers['x-session-id'];
|
||||
const userId = await cache.get(`session:${sessionId}`);
|
||||
const user = await authService.getUserById(Number(userId));
|
||||
|
||||
await cache.close();
|
||||
|
||||
if (user?.operator) {
|
||||
const filePath = `${getConfig().rootFolder}/traefik/tls/cert.pem`;
|
||||
|
||||
if (await fs.pathExists(filePath)) {
|
||||
const file = await fs.promises.readFile(filePath);
|
||||
|
||||
res.setHeader('Content-Type', 'application/x-pem-file');
|
||||
res.setHeader('Content-Dispositon', 'attachment; filename=cert.pem');
|
||||
return res.send(file);
|
||||
}
|
||||
|
||||
res.status(404).send('File not found');
|
||||
}
|
||||
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
|
@ -1,5 +1,24 @@
|
|||
import { setCookie } from 'cookies-next';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { v4 } from 'uuid';
|
||||
import { TipiCache } from '../core/TipiCache/TipiCache';
|
||||
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24; // 1 day
|
||||
const COOKIE_NAME = 'tipi.sid';
|
||||
|
||||
export const generateSessionId = (prefix: string) => {
|
||||
return `${prefix}-${v4()}`;
|
||||
};
|
||||
|
||||
export const setSession = async (sessionId: string, userId: string, req: NextApiRequest, res: NextApiResponse) => {
|
||||
const cache = new TipiCache();
|
||||
|
||||
setCookie(COOKIE_NAME, sessionId, { req, res, maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: false, sameSite: false });
|
||||
|
||||
const sessionKey = `session:${sessionId}`;
|
||||
|
||||
await cache.set(sessionKey, userId);
|
||||
await cache.set(`session:${userId}:${sessionId}`, sessionKey);
|
||||
|
||||
await cache.close();
|
||||
};
|
||||
|
|
|
@ -1,17 +1,12 @@
|
|||
import { inferAsyncReturnType } from '@trpc/server';
|
||||
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
|
||||
import { Locale } from '@/shared/internationalization/locales';
|
||||
|
||||
type Session = {
|
||||
userId?: number;
|
||||
locale?: Locale;
|
||||
};
|
||||
import { TipiCache } from './core/TipiCache/TipiCache';
|
||||
|
||||
type CreateContextOptions = {
|
||||
req: CreateNextContextOptions['req'] & {
|
||||
session?: Session;
|
||||
};
|
||||
req: CreateNextContextOptions['req'];
|
||||
res: CreateNextContextOptions['res'];
|
||||
sessionId: string;
|
||||
userId?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -32,11 +27,20 @@ const createContextInner = async (opts: CreateContextOptions) => ({
|
|||
* @param {CreateNextContextOptions} opts - options
|
||||
*/
|
||||
export const createContext = async (opts: CreateNextContextOptions) => {
|
||||
const cache = new TipiCache();
|
||||
const { req, res } = opts;
|
||||
|
||||
const sessionId = req.headers['x-session-id'] as string;
|
||||
|
||||
const userId = await cache.get(`session:${sessionId}`);
|
||||
|
||||
await cache.close();
|
||||
|
||||
return createContextInner({
|
||||
req,
|
||||
res,
|
||||
sessionId,
|
||||
userId: Number(userId) || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -23,27 +23,35 @@ const combinedLogFormatDev = combine(
|
|||
|
||||
const productionLogger = () => {
|
||||
const logsFolder = '/app/logs';
|
||||
if (!fs.existsSync(logsFolder)) {
|
||||
fs.mkdirSync(logsFolder);
|
||||
try {
|
||||
if (!fs.existsSync(logsFolder)) {
|
||||
fs.mkdirSync(logsFolder);
|
||||
}
|
||||
return createLogger({
|
||||
level: 'info',
|
||||
format: combinedLogFormat,
|
||||
transports: [
|
||||
//
|
||||
// - Write to all logs with level `info` and below to `app.log`
|
||||
// - Write all logs error (and below) to `error.log`.
|
||||
//
|
||||
new transports.File({
|
||||
filename: path.join(logsFolder, 'error.log'),
|
||||
level: 'error',
|
||||
}),
|
||||
new transports.File({
|
||||
filename: path.join(logsFolder, 'app.log'),
|
||||
}),
|
||||
],
|
||||
exceptionHandlers: [new transports.File({ filename: path.join(logsFolder, 'error.log') })],
|
||||
});
|
||||
} catch (e) {
|
||||
return createLogger({
|
||||
level: 'info',
|
||||
format: combinedLogFormat,
|
||||
transports: [],
|
||||
});
|
||||
}
|
||||
return createLogger({
|
||||
level: 'info',
|
||||
format: combinedLogFormat,
|
||||
transports: [
|
||||
//
|
||||
// - Write to all logs with level `info` and below to `app.log`
|
||||
// - Write all logs error (and below) to `error.log`.
|
||||
//
|
||||
new transports.File({
|
||||
filename: path.join(logsFolder, 'error.log'),
|
||||
level: 'error',
|
||||
}),
|
||||
new transports.File({
|
||||
filename: path.join(logsFolder, 'app.log'),
|
||||
}),
|
||||
],
|
||||
exceptionHandlers: [new transports.File({ filename: path.join(logsFolder, 'error.log') })],
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
|
|
|
@ -4,7 +4,7 @@ import { getConfig } from '../TipiConfig';
|
|||
|
||||
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
|
||||
|
||||
class TipiCache {
|
||||
export class TipiCache {
|
||||
private static instance: TipiCache;
|
||||
|
||||
private client: RedisClientType;
|
||||
|
@ -78,5 +78,3 @@ class TipiCache {
|
|||
return client.ttl(key);
|
||||
}
|
||||
}
|
||||
|
||||
export default TipiCache.getInstance();
|
||||
|
|
|
@ -1 +1 @@
|
|||
export { default } from './TipiCache';
|
||||
export { TipiCache } from './TipiCache';
|
||||
|
|
|
@ -1,85 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
/* eslint-disable global-require */
|
||||
import express from 'express';
|
||||
import { parse } from 'url';
|
||||
|
||||
import type { NextServer } from 'next/dist/server/next';
|
||||
import { EventDispatcher } from './core/EventDispatcher';
|
||||
import { getConfig, setConfig } from './core/TipiConfig';
|
||||
import { Logger } from './core/Logger';
|
||||
import { runPostgresMigrations } from './run-migration';
|
||||
import { AppServiceClass } from './services/apps/apps.service';
|
||||
import { db } from './db';
|
||||
import { sessionMiddleware } from './middlewares/session.middleware';
|
||||
import { AuthQueries } from './queries/auth/auth.queries';
|
||||
|
||||
let conf = {};
|
||||
let nextApp: NextServer;
|
||||
|
||||
const hostname = 'localhost';
|
||||
const port = parseInt(process.env.PORT || '3000', 10);
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
if (!dev) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const NextServer = require('next/dist/server/next-server').default;
|
||||
conf = require('./.next/required-server-files.json').config;
|
||||
nextApp = new NextServer({ hostname: 'localhost', dev, port, customServer: true, conf });
|
||||
} else {
|
||||
const next = require('next');
|
||||
nextApp = next({ dev, hostname, port });
|
||||
}
|
||||
|
||||
const handle = nextApp.getRequestHandler();
|
||||
|
||||
nextApp.prepare().then(async () => {
|
||||
const authService = new AuthQueries(db);
|
||||
const app = express();
|
||||
|
||||
app.disable('x-powered-by');
|
||||
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
app.use('/static', express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/`));
|
||||
|
||||
app.use('/certificate', async (req, res) => {
|
||||
const userId = req.session?.userId;
|
||||
const user = await authService.getUserById(userId as number);
|
||||
|
||||
if (user?.operator) {
|
||||
res.setHeader('Content-Dispositon', 'attachment; filename=cert.pem');
|
||||
return res.sendFile(`${getConfig().rootFolder}/traefik/tls/cert.pem`);
|
||||
}
|
||||
|
||||
return res.status(403).send('Forbidden');
|
||||
});
|
||||
|
||||
app.all('*', (req, res) => {
|
||||
const parsedUrl = parse(req.url, true);
|
||||
|
||||
handle(req, res, parsedUrl);
|
||||
});
|
||||
|
||||
app.listen(port, async () => {
|
||||
await EventDispatcher.clear();
|
||||
const appService = new AppServiceClass(db);
|
||||
|
||||
// Run database migrations
|
||||
if (getConfig().NODE_ENV !== 'development') {
|
||||
await runPostgresMigrations();
|
||||
}
|
||||
setConfig('status', 'RUNNING');
|
||||
|
||||
// Clone and update apps repo
|
||||
await EventDispatcher.dispatchEventAsync({ type: 'repo', command: 'clone', url: getConfig().appsRepoUrl });
|
||||
await EventDispatcher.dispatchEventAsync({ type: 'repo', command: 'update', url: getConfig().appsRepoUrl });
|
||||
|
||||
// Scheduled events
|
||||
EventDispatcher.scheduleEvent({ type: 'repo', command: 'update', url: getConfig().appsRepoUrl }, '*/30 * * * *');
|
||||
EventDispatcher.scheduleEvent({ type: 'system', command: 'system_info' }, '* * * * *');
|
||||
|
||||
appService.startAllApps();
|
||||
|
||||
Logger.info(`> Server listening at http://localhost:${port} as ${dev ? 'development' : process.env.NODE_ENV}`);
|
||||
});
|
||||
});
|
|
@ -1,22 +0,0 @@
|
|||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { sessionMiddleware } from './session.middleware';
|
||||
|
||||
describe('Session Middleware', () => {
|
||||
it('should redirect to /login if there is no user id in session', async () => {
|
||||
// arrange
|
||||
let session;
|
||||
const app = express();
|
||||
app.use(sessionMiddleware);
|
||||
app.use('/test', (req, res) => {
|
||||
session = req.session;
|
||||
res.send('ok');
|
||||
});
|
||||
|
||||
// act
|
||||
await request(app).get('/test');
|
||||
|
||||
// assert
|
||||
expect(session).toHaveProperty('cookie');
|
||||
});
|
||||
});
|
|
@ -1,27 +0,0 @@
|
|||
import RedisStore from 'connect-redis';
|
||||
import session from 'express-session';
|
||||
import { createClient } from 'redis';
|
||||
import { getConfig } from '../core/TipiConfig';
|
||||
|
||||
// Initialize client.
|
||||
const redisClient = createClient({
|
||||
url: `redis://${getConfig().REDIS_HOST}:6379`,
|
||||
password: getConfig().redisPassword,
|
||||
});
|
||||
redisClient.connect();
|
||||
|
||||
const redisStore = new RedisStore({
|
||||
client: redisClient,
|
||||
prefix: 'tipi:',
|
||||
});
|
||||
|
||||
const COOKIE_MAX_AGE = 1000 * 60 * 60 * 24; // 1 day
|
||||
|
||||
export const sessionMiddleware = session({
|
||||
name: 'tipi.sid',
|
||||
cookie: { maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: false, sameSite: false },
|
||||
store: redisStore,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
secret: getConfig().jwtSecret,
|
||||
});
|
|
@ -1,364 +0,0 @@
|
|||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import { TestDatabase, clearDatabase, closeDatabase, setupTestSuite } from '@/server/tests/test-utils';
|
||||
import { createUser } from '@/server/tests/user.factory';
|
||||
import { AuthRouter } from './auth.router';
|
||||
|
||||
let db: TestDatabase;
|
||||
let authRouter: AuthRouter;
|
||||
const TEST_SUITE = 'authrouter';
|
||||
jest.mock('fs-extra');
|
||||
|
||||
beforeAll(async () => {
|
||||
const testSuite = await setupTestSuite(TEST_SUITE);
|
||||
db = testSuite;
|
||||
authRouter = (await import('./auth.router')).authRouter;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearDatabase(db);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeDatabase(db);
|
||||
});
|
||||
|
||||
describe('Test: login', () => {
|
||||
it('should be accessible without an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.login({ password: '123456', username: 'test' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).not.toBe('UNAUTHORIZED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: logout', () => {
|
||||
it('should not be accessible without an account', async () => {
|
||||
// arrange
|
||||
// @ts-expect-error - we're testing the error case
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456, destroy: (cb) => cb() } } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.logout();
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('should be accessible with an account', async () => {
|
||||
// arrange
|
||||
await createUser({ id: 123456 }, db);
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.logout();
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).not.toBe('UNAUTHORIZED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: register', () => {
|
||||
it('should be accessible without an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.register({ username: 'test@test.com', password: '123', locale: 'en' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).not.toBe('UNAUTHORIZED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: me', () => {
|
||||
it('should be accessible without an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
|
||||
|
||||
// act
|
||||
const result = await caller.me();
|
||||
|
||||
// assert
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should be accessible with an account', async () => {
|
||||
// arrange
|
||||
await createUser({ id: 123456 }, db);
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } }));
|
||||
|
||||
// act
|
||||
const result = await caller.me();
|
||||
|
||||
// assert
|
||||
expect(result).not.toBe(null);
|
||||
expect(result?.id).toBe(123456);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: isConfigured', () => {
|
||||
it('should be accessible without an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
|
||||
|
||||
// act
|
||||
const result = await caller.isConfigured();
|
||||
|
||||
// assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: verifyTotp', () => {
|
||||
it('should be accessible without an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.verifyTotp({ totpCode: '123456', totpSessionId: '123456' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).not.toBe('UNAUTHORIZED');
|
||||
expect(error?.code).toBeDefined();
|
||||
expect(error?.code).not.toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: getTotpUri', () => {
|
||||
it('should not be accessible without an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.getTotpUri({ password: '123456' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('should be accessible with an account', async () => {
|
||||
// arrange
|
||||
await createUser({ id: 123456 }, db);
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.getTotpUri({ password: '123456' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).not.toBe('UNAUTHORIZED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: setupTotp', () => {
|
||||
it('should not be accessible without an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.setupTotp({ totpCode: '123456' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('should be accessible with an account', async () => {
|
||||
// arrange
|
||||
await createUser({ id: 123456 }, db);
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.setupTotp({ totpCode: '123456' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).not.toBe('UNAUTHORIZED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: disableTotp', () => {
|
||||
it('should not be accessible without an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.disableTotp({ password: '123456' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('should be accessible with an account', async () => {
|
||||
// arrange
|
||||
await createUser({ id: 123456 }, db);
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
|
||||
try {
|
||||
await caller.disableTotp({ password: '112321' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).not.toBe('UNAUTHORIZED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: changeOperatorPassword', () => {
|
||||
it('should be accessible without an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.changeOperatorPassword({ newPassword: '222' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).not.toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('should be accessible with an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 122 } } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.changeOperatorPassword({ newPassword: '222' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).not.toBe('UNAUTHORIZED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: resetPassword', () => {
|
||||
it('should not be accessible without an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.changePassword({ currentPassword: '111', newPassword: '222' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('should be accessible with an account', async () => {
|
||||
// arrange
|
||||
await createUser({ id: 122 }, db);
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 122 } } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.changePassword({ currentPassword: '111', newPassword: '222' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).not.toBe('UNAUTHORIZED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: changeLocale', () => {
|
||||
it('should not be accessible without an account', async () => {
|
||||
// arrange
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.changeLocale({ locale: 'en' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('should be accessible with an account', async () => {
|
||||
// arrange
|
||||
await createUser({ id: 122, locale: 'en' }, db);
|
||||
const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 122 } } }));
|
||||
let error;
|
||||
|
||||
// act
|
||||
try {
|
||||
await caller.changeLocale({ locale: 'fr-FR' });
|
||||
} catch (e) {
|
||||
error = e as { code: string };
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(error?.code).not.toBe('UNAUTHORIZED');
|
||||
});
|
||||
});
|
|
@ -6,26 +6,26 @@ import { db } from '../../db';
|
|||
const AuthService = new AuthServiceClass(db);
|
||||
|
||||
export const authRouter = router({
|
||||
login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input, ctx }) => AuthService.login({ ...input }, ctx.req)),
|
||||
logout: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.logout(ctx.req)),
|
||||
register: publicProcedure.input(z.object({ username: z.string(), password: z.string(), locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.register({ ...input }, ctx.req)),
|
||||
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.req.session?.userId)),
|
||||
login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input, ctx }) => AuthService.login({ ...input }, ctx.req, ctx.res)),
|
||||
logout: protectedProcedure.mutation(async ({ ctx }) => AuthService.logout(ctx.sessionId)),
|
||||
register: publicProcedure
|
||||
.input(z.object({ username: z.string(), password: z.string(), locale: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => AuthService.register({ ...input }, ctx.req, ctx.res)),
|
||||
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.userId)),
|
||||
isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
|
||||
changeLocale: protectedProcedure
|
||||
.input(z.object({ locale: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.req.session.userId), locale: input.locale })),
|
||||
changeLocale: protectedProcedure.input(z.object({ locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.userId), locale: input.locale })),
|
||||
// Password
|
||||
checkPasswordChangeRequest: publicProcedure.query(AuthServiceClass.checkPasswordChangeRequest),
|
||||
changeOperatorPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changeOperatorPassword({ newPassword: input.newPassword })),
|
||||
cancelPasswordChangeRequest: publicProcedure.mutation(AuthServiceClass.cancelPasswordChangeRequest),
|
||||
changePassword: protectedProcedure
|
||||
.input(z.object({ currentPassword: z.string(), newPassword: z.string() }))
|
||||
.mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.req.session.userId), ...input })),
|
||||
.mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.userId), ...input })),
|
||||
// Totp
|
||||
verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.verifyTotp(input, ctx.req)),
|
||||
getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.req.session.userId), password: input.password })),
|
||||
setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.req.session.userId), totpCode: input.totpCode })),
|
||||
disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.req.session.userId), password: input.password })),
|
||||
verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.verifyTotp(input, ctx.req, ctx.res)),
|
||||
getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.userId), password: input.password })),
|
||||
setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.userId), totpCode: input.totpCode })),
|
||||
disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.userId), password: input.password })),
|
||||
});
|
||||
|
||||
export type AuthRouter = typeof authRouter;
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
import path from 'path';
|
||||
import pg from 'pg';
|
||||
import { migrate } from '@runtipi/postgres-migrations';
|
||||
import { Logger } from './core/Logger';
|
||||
import { getConfig } from './core/TipiConfig';
|
||||
|
||||
export const runPostgresMigrations = async (dbName?: string) => {
|
||||
Logger.info('Starting database migration');
|
||||
|
||||
const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getConfig();
|
||||
|
||||
Logger.info(`Connecting to database ${postgresDatabase} on ${postgresHost} as ${postgresUsername} on port ${postgresPort}`);
|
||||
|
||||
const client = new pg.Client({
|
||||
user: postgresUsername,
|
||||
host: postgresHost,
|
||||
database: dbName || postgresDatabase,
|
||||
password: postgresPassword,
|
||||
port: Number(postgresPort),
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
Logger.info('Client connected');
|
||||
|
||||
try {
|
||||
const { rows } = await client.query('SELECT * FROM migrations');
|
||||
// if rows contains a migration with name 'Initial1657299198975' (legacy typeorm) delete table migrations. As all migrations are idempotent we can safely delete the table and start over.
|
||||
if (rows.find((row) => row.name === 'Initial1657299198975')) {
|
||||
Logger.info('Found legacy migration. Deleting table migrations');
|
||||
await client.query('DROP TABLE migrations');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.info('Migrations table not found, creating it');
|
||||
}
|
||||
|
||||
Logger.info('Running migrations');
|
||||
try {
|
||||
await migrate({ client }, path.join(__dirname, 'migrations'), { skipCreateMigrationTable: true });
|
||||
} catch (e) {
|
||||
Logger.error('Error running migrations. Dropping table migrations and trying again');
|
||||
await client.query('DROP TABLE migrations');
|
||||
await migrate({ client }, path.join(__dirname, 'migrations'), { skipCreateMigrationTable: true });
|
||||
}
|
||||
|
||||
Logger.info('Migration complete');
|
||||
await client.end();
|
||||
};
|
|
@ -1,4 +1,50 @@
|
|||
import { runPostgresMigrations } from './run-migration';
|
||||
import path from 'path';
|
||||
import pg from 'pg';
|
||||
import { migrate } from '@runtipi/postgres-migrations';
|
||||
import { Logger } from './core/Logger';
|
||||
import { getConfig } from './core/TipiConfig';
|
||||
|
||||
export const runPostgresMigrations = async (dbName?: string) => {
|
||||
Logger.info('Starting database migration');
|
||||
|
||||
const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getConfig();
|
||||
|
||||
Logger.info(`Connecting to database ${postgresDatabase} on ${postgresHost} as ${postgresUsername} on port ${postgresPort}`);
|
||||
|
||||
const client = new pg.Client({
|
||||
user: postgresUsername,
|
||||
host: postgresHost,
|
||||
database: dbName || postgresDatabase,
|
||||
password: postgresPassword,
|
||||
port: Number(postgresPort),
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
Logger.info('Client connected');
|
||||
|
||||
try {
|
||||
const { rows } = await client.query('SELECT * FROM migrations');
|
||||
// if rows contains a migration with name 'Initial1657299198975' (legacy typeorm) delete table migrations. As all migrations are idempotent we can safely delete the table and start over.
|
||||
if (rows.find((row) => row.name === 'Initial1657299198975')) {
|
||||
Logger.info('Found legacy migration. Deleting table migrations');
|
||||
await client.query('DROP TABLE migrations');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.info('Migrations table not found, creating it');
|
||||
}
|
||||
|
||||
Logger.info('Running migrations');
|
||||
try {
|
||||
await migrate({ client }, path.join(__dirname, '../../packages/cli/assets/migrations'), { skipCreateMigrationTable: true });
|
||||
} catch (e) {
|
||||
Logger.error('Error running migrations. Dropping table migrations and trying again');
|
||||
await client.query('DROP TABLE migrations');
|
||||
await migrate({ client }, path.join(__dirname, '../../packages/cli/assets/migrations'), { skipCreateMigrationTable: true });
|
||||
}
|
||||
|
||||
Logger.info('Migration complete');
|
||||
await client.end();
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
await runPostgresMigrations();
|
||||
|
|
|
@ -104,6 +104,12 @@ export class AppServiceClass {
|
|||
if (app) {
|
||||
await this.startApp(id);
|
||||
} else {
|
||||
const apps = await this.queries.getApps();
|
||||
|
||||
if (apps.length >= 6 && getConfig().demoMode) {
|
||||
throw new TranslatedError('server-messages.errors.demo-mode-limit');
|
||||
}
|
||||
|
||||
if (exposed && !domain) {
|
||||
throw new TranslatedError('server-messages.errors.domain-required-if-expose-app');
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import fs from 'fs-extra';
|
||||
import { vi } from 'vitest';
|
||||
import * as argon2 from 'argon2';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { TotpAuthenticator } from '@/server/utils/totp';
|
||||
|
@ -7,16 +6,19 @@ import { generateSessionId } from '@/server/common/session.helpers';
|
|||
import { fromAny, fromPartial } from '@total-typescript/shoehorn';
|
||||
import { mockInsert, mockQuery, mockSelect } from '@/tests/mocks/drizzle';
|
||||
import { createDatabase, clearDatabase, closeDatabase, TestDatabase } from '@/server/tests/test-utils';
|
||||
import { v4 } from 'uuid';
|
||||
import { encrypt } from '../../utils/encryption';
|
||||
import { setConfig } from '../../core/TipiConfig';
|
||||
import { createUser, getUserByEmail, getUserById } from '../../tests/user.factory';
|
||||
import { AuthServiceClass } from './auth.service';
|
||||
import TipiCache from '../../core/TipiCache';
|
||||
import { TipiCache } from '../../core/TipiCache';
|
||||
|
||||
let AuthService: AuthServiceClass;
|
||||
let database: TestDatabase;
|
||||
const TEST_SUITE = 'authservice';
|
||||
|
||||
const cache = new TipiCache();
|
||||
|
||||
beforeAll(async () => {
|
||||
setConfig('jwtSecret', 'test');
|
||||
database = await createDatabase(TEST_SUITE);
|
||||
|
@ -30,30 +32,44 @@ beforeEach(async () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await closeDatabase(database);
|
||||
await cache.close();
|
||||
});
|
||||
|
||||
describe('Login', () => {
|
||||
it('Should correclty set session on request object', async () => {
|
||||
// arrange
|
||||
const req = { session: { userId: undefined } };
|
||||
let session = '';
|
||||
const res = {
|
||||
getHeader: () => {},
|
||||
setHeader: (_: unknown, o: string[]) => {
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
session = o[0] as string;
|
||||
},
|
||||
};
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, database);
|
||||
|
||||
// act
|
||||
await AuthService.login({ username: email, password: 'password' }, fromPartial(req));
|
||||
await AuthService.login({ username: email, password: 'password' }, fromPartial({}), fromPartial(res));
|
||||
|
||||
const sessionId = session.split(';')[0]?.split('=')[1];
|
||||
const sessionKey = `session:${sessionId}`;
|
||||
const userId = await cache.get(sessionKey);
|
||||
|
||||
// assert
|
||||
expect(req.session.userId).toBe(user.id);
|
||||
expect(userId).toBeDefined();
|
||||
expect(userId).not.toBeNull();
|
||||
expect(userId).toBe(user.id.toString());
|
||||
});
|
||||
|
||||
it('Should throw if user does not exist', async () => {
|
||||
await expect(AuthService.login({ username: 'test', password: 'test' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.user-not-found');
|
||||
await expect(AuthService.login({ username: 'test', password: 'test' }, fromPartial({}), fromPartial({}))).rejects.toThrowError('server-messages.errors.user-not-found');
|
||||
});
|
||||
|
||||
it('Should throw if password is incorrect', async () => {
|
||||
const email = faker.internet.email();
|
||||
await createUser({ email }, database);
|
||||
await expect(AuthService.login({ username: email, password: 'wrong' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.invalid-credentials');
|
||||
await expect(AuthService.login({ username: email, password: 'wrong' }, fromPartial({}), fromPartial({}))).rejects.toThrowError('server-messages.errors.invalid-credentials');
|
||||
});
|
||||
|
||||
// TOTP
|
||||
|
@ -64,7 +80,7 @@ describe('Login', () => {
|
|||
await createUser({ email, totpEnabled: true, totpSecret }, database);
|
||||
|
||||
// act
|
||||
const { totpSessionId } = await AuthService.login({ username: email, password: 'password' }, fromPartial({}));
|
||||
const { totpSessionId } = await AuthService.login({ username: email, password: 'password' }, fromPartial({}), fromPartial({}));
|
||||
|
||||
// assert
|
||||
expect(totpSessionId).toBeDefined();
|
||||
|
@ -75,7 +91,14 @@ describe('Login', () => {
|
|||
describe('Test: verifyTotp', () => {
|
||||
it('should correctly log in user after totp is verified', async () => {
|
||||
// arrange
|
||||
const req = { session: { userId: undefined } };
|
||||
let session = '';
|
||||
const res = {
|
||||
getHeader: () => {},
|
||||
setHeader: (_: unknown, o: string[]) => {
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
session = o[0] as string;
|
||||
},
|
||||
};
|
||||
const email = faker.internet.email();
|
||||
const salt = faker.lorem.word();
|
||||
const totpSecret = TotpAuthenticator.generateSecret();
|
||||
|
@ -85,17 +108,19 @@ describe('Test: verifyTotp', () => {
|
|||
const totpSessionId = generateSessionId('otp');
|
||||
const otp = TotpAuthenticator.generate(totpSecret);
|
||||
|
||||
await TipiCache.set(totpSessionId, user.id.toString());
|
||||
await cache.set(totpSessionId, user.id.toString());
|
||||
|
||||
// act
|
||||
const result = await AuthService.verifyTotp({ totpSessionId, totpCode: otp }, fromPartial(req));
|
||||
const result = await AuthService.verifyTotp({ totpSessionId, totpCode: otp }, fromPartial({}), fromPartial(res));
|
||||
const sessionId = session.split(';')[0]?.split('=')[1];
|
||||
const userId = await cache.get(`session:${sessionId}`);
|
||||
|
||||
// assert
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).not.toBeNull();
|
||||
expect(req.session.userId).toBeDefined();
|
||||
expect(req.session.userId).not.toBeNull();
|
||||
expect(req.session.userId).toBe(user.id);
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(sessionId).not.toBeNull();
|
||||
expect(userId).toBe(user.id.toString());
|
||||
});
|
||||
|
||||
it('should throw if the totp is incorrect', async () => {
|
||||
|
@ -106,10 +131,10 @@ describe('Test: verifyTotp', () => {
|
|||
const encryptedTotpSecret = encrypt(totpSecret, salt);
|
||||
const user = await createUser({ email, totpEnabled: true, totpSecret: encryptedTotpSecret, salt }, database);
|
||||
const totpSessionId = generateSessionId('otp');
|
||||
await TipiCache.set(totpSessionId, user.id.toString());
|
||||
await cache.set(totpSessionId, user.id.toString());
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: 'wrong' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.totp-invalid-code');
|
||||
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: 'wrong' }, fromPartial({}), fromPartial({}))).rejects.toThrowError('server-messages.errors.totp-invalid-code');
|
||||
});
|
||||
|
||||
it('should throw if the totpSessionId is invalid', async () => {
|
||||
|
@ -122,19 +147,19 @@ describe('Test: verifyTotp', () => {
|
|||
const totpSessionId = generateSessionId('otp');
|
||||
const otp = TotpAuthenticator.generate(totpSecret);
|
||||
|
||||
await TipiCache.set(totpSessionId, user.id.toString());
|
||||
await cache.set(totpSessionId, user.id.toString());
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.verifyTotp({ totpSessionId: 'wrong', totpCode: otp }, fromPartial({}))).rejects.toThrowError('server-messages.errors.totp-session-not-found');
|
||||
await expect(AuthService.verifyTotp({ totpSessionId: 'wrong', totpCode: otp }, fromPartial({}), fromPartial({}))).rejects.toThrowError('server-messages.errors.totp-session-not-found');
|
||||
});
|
||||
|
||||
it('should throw if the user does not exist', async () => {
|
||||
// arrange
|
||||
const totpSessionId = generateSessionId('otp');
|
||||
await TipiCache.set(totpSessionId, '1234');
|
||||
await cache.set(totpSessionId, '1234');
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.user-not-found');
|
||||
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' }, fromPartial({}), fromPartial({}))).rejects.toThrowError('server-messages.errors.user-not-found');
|
||||
});
|
||||
|
||||
it('should throw if the user totpEnabled is false', async () => {
|
||||
|
@ -147,10 +172,10 @@ describe('Test: verifyTotp', () => {
|
|||
const totpSessionId = generateSessionId('otp');
|
||||
const otp = TotpAuthenticator.generate(totpSecret);
|
||||
|
||||
await TipiCache.set(totpSessionId, user.id.toString());
|
||||
await cache.set(totpSessionId, user.id.toString());
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: otp }, fromPartial({}))).rejects.toThrowError('server-messages.errors.totp-not-enabled');
|
||||
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: otp }, fromPartial({}), fromPartial({}))).rejects.toThrowError('server-messages.errors.totp-not-enabled');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -353,26 +378,39 @@ describe('Test: disableTotp', () => {
|
|||
});
|
||||
|
||||
describe('Register', () => {
|
||||
it('Should correctly set session on request object', async () => {
|
||||
it('Should correctly set session on response object', async () => {
|
||||
// arrange
|
||||
const req = { session: { userId: undefined } };
|
||||
let session = '';
|
||||
const res = {
|
||||
getHeader: () => {},
|
||||
setHeader: (_: unknown, o: string[]) => {
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
session = o[0] as string;
|
||||
},
|
||||
};
|
||||
const email = faker.internet.email();
|
||||
|
||||
// act
|
||||
const result = await AuthService.register({ username: email, password: 'password' }, fromPartial(req));
|
||||
const result = await AuthService.register({ username: email, password: 'password' }, fromPartial({}), fromPartial(res));
|
||||
const sessionId = session.split(';')[0]?.split('=')[1];
|
||||
|
||||
// assert
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).not.toBeNull();
|
||||
expect(req.session.userId).toBeDefined();
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(sessionId).not.toBeNull();
|
||||
});
|
||||
|
||||
it('Should correctly trim and lowercase email', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const res = {
|
||||
getHeader: () => {},
|
||||
setHeader: () => {},
|
||||
};
|
||||
|
||||
// act
|
||||
await AuthService.register({ username: email, password: 'test' }, fromPartial({ session: {} }));
|
||||
await AuthService.register({ username: email, password: 'test' }, fromPartial({}), fromPartial(res));
|
||||
const user = await getUserByEmail(email.toLowerCase().trim(), database);
|
||||
|
||||
// assert
|
||||
|
@ -386,7 +424,7 @@ describe('Register', () => {
|
|||
|
||||
// Act & Assert
|
||||
await createUser({ email, operator: true }, database);
|
||||
await expect(AuthService.register({ username: email, password: 'test' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.admin-already-exists');
|
||||
await expect(AuthService.register({ username: email, password: 'test' }, fromPartial({}), fromPartial({}))).rejects.toThrowError('server-messages.errors.admin-already-exists');
|
||||
});
|
||||
|
||||
it('Should throw if user already exists', async () => {
|
||||
|
@ -395,23 +433,27 @@ describe('Register', () => {
|
|||
|
||||
// Act & Assert
|
||||
await createUser({ email, operator: false }, database);
|
||||
await expect(AuthService.register({ username: email, password: 'test' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.user-already-exists');
|
||||
await expect(AuthService.register({ username: email, password: 'test' }, fromPartial({}), fromPartial({}))).rejects.toThrowError('server-messages.errors.user-already-exists');
|
||||
});
|
||||
|
||||
it('Should throw if email is not provided', async () => {
|
||||
await expect(AuthService.register({ username: '', password: 'test' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.missing-email-or-password');
|
||||
await expect(AuthService.register({ username: '', password: 'test' }, fromPartial({}), fromPartial({}))).rejects.toThrowError('server-messages.errors.missing-email-or-password');
|
||||
});
|
||||
|
||||
it('Should throw if password is not provided', async () => {
|
||||
await expect(AuthService.register({ username: faker.internet.email(), password: '' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.missing-email-or-password');
|
||||
await expect(AuthService.register({ username: faker.internet.email(), password: '' }, fromPartial({}), fromPartial({}))).rejects.toThrowError('server-messages.errors.missing-email-or-password');
|
||||
});
|
||||
|
||||
it('Password is correctly hashed', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email().toLowerCase().trim();
|
||||
const res = {
|
||||
getHeader: () => {},
|
||||
setHeader: () => {},
|
||||
};
|
||||
|
||||
// act
|
||||
await AuthService.register({ username: email, password: 'test' }, fromPartial({ session: {} }));
|
||||
await AuthService.register({ username: email, password: 'test' }, fromPartial({}), fromPartial(res));
|
||||
const user = await getUserByEmail(email, database);
|
||||
const isPasswordValid = await argon2.verify(user?.password || '', 'test');
|
||||
|
||||
|
@ -420,7 +462,7 @@ describe('Register', () => {
|
|||
});
|
||||
|
||||
it('Should throw if email is invalid', async () => {
|
||||
await expect(AuthService.register({ username: 'test', password: 'test' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.invalid-username');
|
||||
await expect(AuthService.register({ username: 'test', password: 'test' }, fromPartial({}), fromPartial({}))).rejects.toThrowError('server-messages.errors.invalid-username');
|
||||
});
|
||||
|
||||
it('should throw if db fails to insert user', async () => {
|
||||
|
@ -431,15 +473,14 @@ describe('Register', () => {
|
|||
const newAuthService = new AuthServiceClass(fromAny(mockDatabase));
|
||||
|
||||
// Act & Assert
|
||||
await expect(newAuthService.register({ username: email, password: 'test' }, fromPartial(req))).rejects.toThrowError('server-messages.errors.error-creating-user');
|
||||
await expect(newAuthService.register({ username: email, password: 'test' }, fromPartial(req), fromPartial({}))).rejects.toThrowError('server-messages.errors.error-creating-user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: logout', () => {
|
||||
it('Should return true if there is no session to delete', async () => {
|
||||
// act
|
||||
const req = {};
|
||||
const result = await AuthServiceClass.logout(fromPartial(req));
|
||||
const result = await AuthService.logout('session');
|
||||
|
||||
// assert
|
||||
expect(result).toBe(true);
|
||||
|
@ -447,15 +488,17 @@ describe('Test: logout', () => {
|
|||
|
||||
it('Should destroy session upon logount', async () => {
|
||||
// arrange
|
||||
const destroy = vi.fn();
|
||||
const req = { session: { userId: 1, destroy } };
|
||||
const sessionId = v4();
|
||||
|
||||
await cache.set(`session:${sessionId}`, '1');
|
||||
|
||||
// act
|
||||
const result = await AuthServiceClass.logout(fromPartial(req));
|
||||
const result = await AuthService.logout(sessionId);
|
||||
const session = await cache.get(`session:${sessionId}`);
|
||||
|
||||
// assert
|
||||
expect(result).toBe(true);
|
||||
expect(destroy).toHaveBeenCalled();
|
||||
expect(session).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -675,14 +718,14 @@ describe('Test: changePassword', () => {
|
|||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, database);
|
||||
const newPassword = faker.internet.password();
|
||||
await TipiCache.set(`session:${user.id}:${faker.lorem.word()}`, 'test');
|
||||
await cache.set(`session:${user.id}:${faker.lorem.word()}`, 'test');
|
||||
|
||||
// act
|
||||
await AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' });
|
||||
|
||||
// assert
|
||||
// eslint-disable-next-line testing-library/no-await-sync-query
|
||||
const sessions = await TipiCache.getByPrefix(`session:${user.id}:`);
|
||||
const sessions = await cache.getByPrefix(`session:${user.id}:`);
|
||||
expect(sessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as argon2 from 'argon2';
|
||||
import validator from 'validator';
|
||||
import { TotpAuthenticator } from '@/server/utils/totp';
|
||||
import { AuthQueries } from '@/server/queries/auth/auth.queries';
|
||||
import { Context } from '@/server/context';
|
||||
import { TranslatedError } from '@/server/utils/errors';
|
||||
import { Locales, getLocaleFromString } from '@/shared/internationalization/locales';
|
||||
import { generateSessionId } from '@/server/common/session.helpers';
|
||||
import { generateSessionId, setSession } from '@/server/common/session.helpers';
|
||||
import { Database } from '@/server/db';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getConfig } from '../../core/TipiConfig';
|
||||
import TipiCache from '../../core/TipiCache';
|
||||
import { TipiCache } from '../../core/TipiCache';
|
||||
import { fileExists, unlinkFile } from '../../common/fs.helpers';
|
||||
import { decrypt, encrypt } from '../../utils/encryption';
|
||||
|
||||
|
@ -21,17 +22,21 @@ type UsernamePasswordInput = {
|
|||
export class AuthServiceClass {
|
||||
private queries;
|
||||
|
||||
private cache;
|
||||
|
||||
constructor(p: Database) {
|
||||
this.queries = new AuthQueries(p);
|
||||
this.cache = new TipiCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user with given username and password
|
||||
*
|
||||
* @param {UsernamePasswordInput} input - An object containing the user's username and password
|
||||
* @param {Request} req - The Next.js request object
|
||||
* @param {NextApiRequest} req - The Next.js request object
|
||||
* @param {NextApiResponse} res - The Next.js response object
|
||||
*/
|
||||
public login = async (input: UsernamePasswordInput, req: Context['req']) => {
|
||||
public login = async (input: UsernamePasswordInput, req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { password, username } = input;
|
||||
const user = await this.queries.getUserByUsername(username);
|
||||
|
||||
|
@ -47,12 +52,12 @@ export class AuthServiceClass {
|
|||
|
||||
if (user.totpEnabled) {
|
||||
const totpSessionId = generateSessionId('otp');
|
||||
await TipiCache.set(totpSessionId, user.id.toString());
|
||||
await this.cache.set(totpSessionId, user.id.toString());
|
||||
return { totpSessionId };
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
await TipiCache.set(`session:${user.id}:${req.session.id}`, req.session.id);
|
||||
const sessionId = uuidv4();
|
||||
await setSession(sessionId, user.id.toString(), req, res);
|
||||
|
||||
return {};
|
||||
};
|
||||
|
@ -63,11 +68,12 @@ export class AuthServiceClass {
|
|||
* @param {object} params - An object containing the TOTP session ID and the TOTP code
|
||||
* @param {string} params.totpSessionId - The TOTP session ID
|
||||
* @param {string} params.totpCode - The TOTP code
|
||||
* @param {Request} req - The Next.js request object
|
||||
* @param {NextApiRequest} req - The Next.js request object
|
||||
* @param {NextApiResponse} res - The Next.js response object
|
||||
*/
|
||||
public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: Context['req']) => {
|
||||
public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { totpSessionId, totpCode } = params;
|
||||
const userId = await TipiCache.get(totpSessionId);
|
||||
const userId = await this.cache.get(totpSessionId);
|
||||
|
||||
if (!userId) {
|
||||
throw new TranslatedError('server-messages.errors.totp-session-not-found');
|
||||
|
@ -90,7 +96,8 @@ export class AuthServiceClass {
|
|||
throw new TranslatedError('server-messages.errors.totp-invalid-code');
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
const sessionId = uuidv4();
|
||||
await setSession(sessionId, user.id.toString(), req, res);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
@ -195,9 +202,10 @@ export class AuthServiceClass {
|
|||
* Creates a new user with the provided email and password and returns a session token
|
||||
*
|
||||
* @param {UsernamePasswordInput} input - An object containing the email and password fields
|
||||
* @param {Request} req - The Next.js request object
|
||||
* @param {NextApiRequest} req - The Next.js request object
|
||||
* @param {NextApiResponse} res - The Next.js response object
|
||||
*/
|
||||
public register = async (input: UsernamePasswordInput, req: Context['req']) => {
|
||||
public register = async (input: UsernamePasswordInput, req: NextApiRequest, res: NextApiResponse) => {
|
||||
const operators = await this.queries.getOperators();
|
||||
|
||||
if (operators.length > 0) {
|
||||
|
@ -229,8 +237,8 @@ export class AuthServiceClass {
|
|||
throw new TranslatedError('server-messages.errors.error-creating-user');
|
||||
}
|
||||
|
||||
req.session.userId = newUser.id;
|
||||
await TipiCache.set(`session:${newUser.id}:${req.session.id}`, req.session.id);
|
||||
const sessionId = uuidv4();
|
||||
await setSession(sessionId, newUser.id.toString(), req, res);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
@ -253,15 +261,11 @@ export class AuthServiceClass {
|
|||
/**
|
||||
* Logs out the current user by removing the session token
|
||||
*
|
||||
* @param {Request} req - The Next.js request object
|
||||
* @param {string} sessionId - The session token to remove
|
||||
* @returns {Promise<boolean>} - Returns true if the session token is removed successfully
|
||||
*/
|
||||
public static logout = async (req: Context['req']): Promise<boolean> => {
|
||||
if (!req.session) {
|
||||
return true;
|
||||
}
|
||||
|
||||
req.session.destroy(() => {});
|
||||
public logout = async (sessionId: string): Promise<boolean> => {
|
||||
await this.cache.del(`session:${sessionId}`);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
@ -340,12 +344,12 @@ export class AuthServiceClass {
|
|||
* @param {number} userId - The user ID
|
||||
*/
|
||||
private destroyAllSessionsByUserId = async (userId: number) => {
|
||||
const sessions = await TipiCache.getByPrefix(`session:${userId}:`);
|
||||
const sessions = await this.cache.getByPrefix(`session:${userId}:`);
|
||||
|
||||
await Promise.all(
|
||||
sessions.map(async (session) => {
|
||||
await TipiCache.del(session.key);
|
||||
await TipiCache.del(`tipi:${session.val}`);
|
||||
await this.cache.del(session.key);
|
||||
if (session.val) await this.cache.del(session.val);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@ import semver from 'semver';
|
|||
import { faker } from '@faker-js/faker';
|
||||
import { EventDispatcher } from '../../core/EventDispatcher';
|
||||
import { setConfig } from '../../core/TipiConfig';
|
||||
import TipiCache from '../../core/TipiCache';
|
||||
import { TipiCache } from '../../core/TipiCache';
|
||||
import { SystemServiceClass } from '.';
|
||||
|
||||
jest.mock('redis');
|
||||
|
@ -14,6 +14,8 @@ const SystemService = new SystemServiceClass();
|
|||
|
||||
const server = setupServer();
|
||||
|
||||
const cache = new TipiCache();
|
||||
|
||||
beforeEach(async () => {
|
||||
await setConfig('demoMode', false);
|
||||
|
||||
|
@ -71,14 +73,15 @@ describe('Test: getVersion', () => {
|
|||
server.listen();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
server.resetHandlers();
|
||||
TipiCache.del('latestVersion');
|
||||
await cache.del('latestVersion');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
server.close();
|
||||
jest.restoreAllMocks();
|
||||
await cache.close();
|
||||
});
|
||||
|
||||
it('It should return version with body', async () => {
|
||||
|
@ -163,7 +166,7 @@ describe('Test: update', () => {
|
|||
// Arrange
|
||||
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
|
||||
setConfig('version', '0.0.1');
|
||||
TipiCache.set('latestVersion', '0.0.2');
|
||||
await cache.set('latestVersion', '0.0.2');
|
||||
|
||||
// Act
|
||||
const update = await SystemService.update();
|
||||
|
@ -174,7 +177,7 @@ describe('Test: update', () => {
|
|||
|
||||
it('Should throw an error if latest version is not set', async () => {
|
||||
// Arrange
|
||||
TipiCache.del('latestVersion');
|
||||
await cache.del('latestVersion');
|
||||
server.use(
|
||||
rest.get('https://api.github.com/repos/meienberger/runtipi/releases/latest', (_, res, ctx) => {
|
||||
return res(ctx.json({ name: null }));
|
||||
|
@ -189,7 +192,7 @@ describe('Test: update', () => {
|
|||
it('Should throw if current version is higher than latest', async () => {
|
||||
// Arrange
|
||||
setConfig('version', '0.0.2');
|
||||
TipiCache.set('latestVersion', '0.0.1');
|
||||
await cache.set('latestVersion', '0.0.1');
|
||||
|
||||
// Act & Assert
|
||||
await expect(SystemService.update()).rejects.toThrow('server-messages.errors.current-version-is-latest');
|
||||
|
@ -198,7 +201,7 @@ describe('Test: update', () => {
|
|||
it('Should throw if current version is equal to latest', async () => {
|
||||
// Arrange
|
||||
setConfig('version', '0.0.1');
|
||||
TipiCache.set('latestVersion', '0.0.1');
|
||||
await cache.set('latestVersion', '0.0.1');
|
||||
|
||||
// Act & Assert
|
||||
await expect(SystemService.update()).rejects.toThrow('server-messages.errors.current-version-is-latest');
|
||||
|
@ -207,7 +210,7 @@ describe('Test: update', () => {
|
|||
it('Should throw an error if there is a major version difference', async () => {
|
||||
// Arrange
|
||||
setConfig('version', '0.0.1');
|
||||
TipiCache.set('latestVersion', '1.0.0');
|
||||
await cache.set('latestVersion', '1.0.0');
|
||||
|
||||
// Act & Assert
|
||||
await expect(SystemService.update()).rejects.toThrow('server-messages.errors.major-version-update');
|
||||
|
|
|
@ -5,7 +5,7 @@ import { TranslatedError } from '@/server/utils/errors';
|
|||
import { readJsonFile } from '../../common/fs.helpers';
|
||||
import { EventDispatcher } from '../../core/EventDispatcher';
|
||||
import { Logger } from '../../core/Logger';
|
||||
import TipiCache from '../../core/TipiCache';
|
||||
import { TipiCache } from '../../core/TipiCache';
|
||||
import * as TipiConfig from '../../core/TipiConfig';
|
||||
|
||||
const SYSTEM_STATUS = ['UPDATING', 'RESTARTING', 'RUNNING'] as const;
|
||||
|
@ -33,7 +33,7 @@ export class SystemServiceClass {
|
|||
private dispatcher;
|
||||
|
||||
constructor() {
|
||||
this.cache = TipiCache;
|
||||
this.cache = new TipiCache();
|
||||
this.dispatcher = EventDispatcher;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
/* eslint-disable no-restricted-syntax */
|
||||
import pg, { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { runPostgresMigrations } from '../run-migration';
|
||||
import { getConfig } from '../core/TipiConfig';
|
||||
import * as schema from '../db/schema';
|
||||
import { Database } from '../db';
|
||||
import { runPostgresMigrations } from '../run-migrations-dev';
|
||||
|
||||
export type TestDatabase = {
|
||||
client: Pool;
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import superjson from 'superjson';
|
||||
import { typeToFlattenedError, ZodError } from 'zod';
|
||||
import { Locale } from '@/shared/internationalization/locales';
|
||||
import { type Context } from './context';
|
||||
import { AuthQueries } from './queries/auth/auth.queries';
|
||||
import { db } from './db';
|
||||
|
@ -53,19 +52,14 @@ export const publicProcedure = t.procedure;
|
|||
* users are logged in
|
||||
*/
|
||||
const isAuthed = t.middleware(async ({ ctx, next }) => {
|
||||
const userId = ctx.req.session?.userId;
|
||||
const { userId } = ctx;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
|
||||
}
|
||||
|
||||
const user = await authQueries.getUserById(userId);
|
||||
|
||||
if (user?.locale) {
|
||||
ctx.req.session.locale = user.locale as Locale;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
ctx.req.session.destroy(() => {});
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ const APP_LOCALES = {
|
|||
'es-ES': 'Español',
|
||||
'el-GR': 'Ελληνικά',
|
||||
'fr-FR': 'Français',
|
||||
'it-IT': 'Italiano',
|
||||
'hu-HU': 'Magyar',
|
||||
'ja-JP': '日本語',
|
||||
'pl-PL': 'Polski',
|
||||
|
@ -23,6 +24,7 @@ const FALLBACK_LOCALES: { from: BaseLang<keyof typeof APP_LOCALES>; to: keyof ty
|
|||
{ from: 'en', to: 'en-US' },
|
||||
{ from: 'es', to: 'es-ES' },
|
||||
{ from: 'fr', to: 'fr-FR' },
|
||||
{ from: 'it', to: 'it-IT' },
|
||||
{ from: 'ja', to: 'ja-JP' },
|
||||
{ from: 'pl', to: 'pl-PL' },
|
||||
{ from: 'sv', to: 'sv-SE' },
|
||||
|
|
|
@ -11,6 +11,7 @@ jest.mock('react-markdown', () => ({
|
|||
}));
|
||||
jest.mock('remark-breaks', () => () => ({}));
|
||||
jest.mock('remark-gfm', () => () => ({}));
|
||||
jest.mock('rehype-raw', () => () => ({}));
|
||||
|
||||
console.error = jest.fn();
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue