Merge pull request #673 from meienberger/release/2.0.1

Release/2.0.1
This commit is contained in:
Nicolas Meienberger 2023-08-31 23:30:36 +02:00 committed by GitHub
commit 238a791a77
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 3066 additions and 1845 deletions

View file

@ -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,

View file

@ -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

View file

@ -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
View file

@ -3,6 +3,7 @@
.DS_Store
.vscode
.idea
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

View file

@ -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

View file

@ -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

View file

@ -1,7 +1,7 @@
# Tipi — A personal homeserver for everyone
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-36-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-37-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![License](https://img.shields.io/github/license/meienberger/runtipi)](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>

View file

@ -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(),

View file

@ -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);

View file

@ -1,5 +0,0 @@
{
"watch": ["src/server", "packages/shared"],
"exec": "node ./esbuild.js dev",
"ext": "js ts"
}

View file

@ -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",

View file

@ -5,4 +5,4 @@ APPS_REPO_URL=https://test.com/test
ROOT_FOLDER_HOST=/runtipi
STORAGE_PATH=/runtipi
TIPI_VERSION=1
REDIS_PASSWORD=redis

View file

@ -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",

View file

@ -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();
}
};
}

View file

@ -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(() => {});
}
};

View file

@ -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}`);

View file

@ -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)');

View file

@ -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;

View file

@ -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

View file

@ -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}`);
}
});
};

View file

@ -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,
};
});

View 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

File diff suppressed because it is too large Load diff

View file

@ -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
View file

@ -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>>;
}

View file

@ -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 }}>

View file

@ -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>

View 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"
}
}

View 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"
}
}

View file

@ -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": {}
},

View 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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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());

View file

@ -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;

View file

@ -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 (

View file

@ -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} />

View file

@ -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

View file

@ -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
View 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;
}

View 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');
}
}

View 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');
}

View file

@ -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();
};

View file

@ -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,
});
};

View file

@ -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') })],
});
};
//

View file

@ -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();

View file

@ -1 +1 @@
export { default } from './TipiCache';
export { TipiCache } from './TipiCache';

View file

@ -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}`);
});
});

View file

@ -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');
});
});

View file

@ -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,
});

View file

@ -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');
});
});

View file

@ -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;

View file

@ -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();
};

View file

@ -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();

View file

@ -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');
}

View file

@ -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);
});
});

View file

@ -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);
}),
);
};

View file

@ -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');

View file

@ -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;
}

View file

@ -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;

View file

@ -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' });
}

View file

@ -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' },

View file

@ -11,6 +11,7 @@ jest.mock('react-markdown', () => ({
}));
jest.mock('remark-breaks', () => () => ({}));
jest.mock('remark-gfm', () => () => ({}));
jest.mock('rehype-raw', () => () => ({}));
console.error = jest.fn();